Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d7b9ff4
feat(okta): add group push-mapping service methods
barborico Jun 26, 2026
c5c851c
feat(plugins): make group config hooks app-config aware; add config h…
barborico Jun 26, 2026
e03a36e
feat(plugins): rename sync_all_group_membership hook to sync_all_groups
barborico Jun 26, 2026
8b96e01
feat(operations): add ModifyGroupPluginData and route put_group throu…
barborico Jun 26, 2026
ab77418
feat(groups): validate plugin group config when creating a group
barborico Jun 26, 2026
a112813
feat(plugins): support immutable group config fields
barborico Jun 26, 2026
d4c8c03
feat(plugins): let group-config-props reflect a given app's config
barborico Jun 26, 2026
23e83c2
feat(ui): validate plugin config client-side and lock immutable fields
barborico Jun 26, 2026
36d2fa8
feat(api): make the root log level configurable via LOG_LEVEL
barborico Jun 26, 2026
c4771e6
build: add a make target to run sync-app-groups with .env loaded
barborico Jun 26, 2026
7eb989b
chore(plugin): scaffold the google_group_manager plugin package
barborico Jun 26, 2026
8856688
feat(plugin): add config/status schema, validation, and email helpers
barborico Jun 26, 2026
679a0c1
feat(plugin): add Cloud Identity Groups API wrappers for group CRUD
barborico Jun 26, 2026
6ae1b31
feat(plugin): add Okta push-mapping creation and link discovery
barborico Jun 26, 2026
9bd0838
feat(plugin): add the idempotent reconcile core
barborico Jun 26, 2026
9c081fd
feat(plugin): wire up lifecycle hooks and bulk sync
barborico Jun 26, 2026
c11f13b
style: apply ruff format to app group lifecycle plugin and edits
barborico Jun 26, 2026
533f3c1
style: add type annotations to google_group_manager test_plugin
barborico Jun 26, 2026
ea6855b
Ship the Google group lifecycle plugin source in the image
barborico Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ COPY requirements.txt api/ ./api/
COPY migrations/ ./migrations/
COPY alembic.ini setup.py ./
COPY ./config ./config

# Ship prod-ready plugin source into the image WITHOUT installing it here.
# Downstream images opt in by pip installing the plugins,
# which keeps the source single-homed in this repo while leaving activation to the consumer.
COPY ./examples/plugins/app_group_lifecycle_google ./plugins/app_group_lifecycle_google

RUN pip install -r ./api/requirements.txt
# Install the project itself so the `access` console script declared in
# setup.py (entry_points -> api.manage:cli) is available on PATH for
Expand Down
17 changes: 15 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ help:
@echo "Management commands:"
@echo " make sync access sync"
@echo " make notify access notify"
@echo " make sync-app-groups access sync-app-groups (loads .env)"
@echo ""
@echo "Docker:"
@echo " make build docker build"
Expand Down Expand Up @@ -80,7 +81,7 @@ run: .env dev db-migrate
@mkdir -p .claude
@printf '%s\n' "$(BACKEND_PORT)" > .claude/.api-port
$(ACTIVATE) DATABASE_URI=$(LOCAL_DB_URI) \
uvicorn --reload --host 0.0.0.0 --port $(BACKEND_PORT) api.asgi:app & \
uvicorn --env-file .env --reload --host 0.0.0.0 --port $(BACKEND_PORT) api.asgi:app & \
npm install && npx vite --host 0.0.0.0 --port $(FRONTEND_PORT)

.PHONY: run-frontend
Expand All @@ -92,7 +93,7 @@ run-backend: .env dev db-migrate
@mkdir -p .claude
@printf '%s\n' "$(BACKEND_PORT)" > .claude/.api-port
$(ACTIVATE) DATABASE_URI=$(LOCAL_DB_URI) \
uvicorn --reload --host 0.0.0.0 --port $(BACKEND_PORT) api.asgi:app
uvicorn --env-file .env --reload --host 0.0.0.0 --port $(BACKEND_PORT) api.asgi:app

# ----------------------------------------------------------------------
# Database / migrations
Expand Down Expand Up @@ -136,6 +137,18 @@ sync: dev
notify: dev
$(ACTIVATE) DATABASE_URI=$(LOCAL_DB_URI) access notify

# Unlike `sync`/`notify`, this loads the full .env so the app-group lifecycle
# plugins get the credentials they need (e.g. Google/Okta). `access` has no
# --env-file flag, so we source .env into the environment ourselves: `set -a`
# auto-exports every var defined while sourcing. We then force
# DATABASE_URI=$(LOCAL_DB_URI) -- exactly like `sync`/`notify`/`run-backend` --
# so this points at the same migrated instance/access.db the dev server uses,
# rather than whatever DATABASE_URI .env happens to set.
.PHONY: sync-app-groups
sync-app-groups: .env dev
$(ACTIVATE) set -a && . ./.env && set +a && \
DATABASE_URI=$(LOCAL_DB_URI) access sync-app-groups

# ----------------------------------------------------------------------
# Docker
# ----------------------------------------------------------------------
Expand Down
5 changes: 3 additions & 2 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ def _flatten_query_param_models(openapi_schema: dict[str, Any]) -> None:


def _configure_logging() -> None:
logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(message)s")
logging.root.setLevel(logging.INFO)
level = environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=level, stream=sys.stdout, format="%(message)s")
logging.root.setLevel(level)
token_filter = TokenSanitizingFilter()
logging.getLogger("authlib").addFilter(token_filter)
logging.getLogger("uvicorn.access").addFilter(token_filter)
Expand Down
27 changes: 18 additions & 9 deletions api/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
db.init_app(engine=build_engine())
# Trigger plugin discovery once per CLI run. Notification, conditional
# access, and app-group-lifecycle hooks are all consumed by the
# `sync` / `notify` / `sync-app-group-memberships` commands; the
# `sync` / `notify` / `sync-app-groups` commands; the
# `init` family doesn't need them but the call is cheap (memoized).
load_plugins()
token = _session_scope.set(f"cli-{uuid.uuid4().hex}")
Expand Down Expand Up @@ -308,13 +308,10 @@ def notify(owner: bool, role_owner: bool) -> None:
expiring_access_notifications_user()


@cli.command("sync-app-group-memberships")
@_with_app_context
def sync_app_group_memberships() -> None:
"""Invoke the periodic membership sync hook for all apps with app group lifecycle plugins configured."""
def _sync_all_app_groups() -> None:
from api.extensions import db
from api.models import App
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook
from api.plugins.app_group_lifecycle import invoke_sync_all_groups

click.echo("Starting app group lifecycle plugin sync")

Expand All @@ -330,12 +327,10 @@ def sync_app_group_memberships() -> None:

click.echo(f"Found {len(apps)} app(s) with plugins configured")

hook = get_app_group_lifecycle_hook()

for app in apps:
click.echo(f"Syncing app '{app.name}' (plugin: {app.app_group_lifecycle_plugin})")
try:
hook.sync_all_group_membership(session=db.session, app=app, plugin_id=app.app_group_lifecycle_plugin)
invoke_sync_all_groups(session=db.session, app=app, plugin_id=app.app_group_lifecycle_plugin)
db.session.commit()
click.echo(f" ✓ Synced app '{app.name}'")
except Exception as e:
Expand All @@ -345,5 +340,19 @@ def sync_app_group_memberships() -> None:
click.echo("Completed app group lifecycle plugin sync")


@cli.command("sync-app-groups")
@_with_app_context
def sync_app_groups() -> None:
"""Invoke the periodic group-sync hook for all apps with app group lifecycle plugins configured."""
_sync_all_app_groups()


@cli.command("sync-app-group-memberships", hidden=True)
@_with_app_context
def sync_app_group_memberships() -> None:
"""Deprecated alias for `sync-app-groups`; kept so existing automation keeps working."""
_sync_all_app_groups()


if __name__ == "__main__":
cli()
2 changes: 2 additions & 0 deletions api/operations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from api.operations.modify_app_tags import ModifyAppTags
from api.operations.modify_group_tags import ModifyGroupTags
from api.operations.modify_group_details import ModifyGroupDetails
from api.operations.modify_group_plugin_data import ModifyGroupPluginData
from api.operations.modify_group_type import ModifyGroupType
from api.operations.modify_group_users import ModifyGroupUsers
from api.operations.modify_role_groups import ModifyRoleGroups
Expand All @@ -39,6 +40,7 @@
"CreateGroup",
"ModifyAppTags",
"ModifyGroupDetails",
"ModifyGroupPluginData",
"ModifyGroupTags",
"ModifyGroupType",
"ModifyGroupUsers",
Expand Down
81 changes: 81 additions & 0 deletions api/operations/modify_group_plugin_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import logging
from typing import Any

from api.extensions import db
from api.models import AppGroup, OktaGroup
from api.plugins.app_group_lifecycle import (
get_app_group_lifecycle_hook,
get_app_group_lifecycle_plugin_to_invoke,
is_plugin_config_changed,
merge_app_lifecycle_plugin_data,
)


class ModifyGroupPluginData:
"""Apply a (partial) plugin_data patch to a group and fire group_updated when the
configured app group lifecycle plugin's *configuration* changes.

Owns the plugin_data merge semantics so callers only supply the patch:
- top-level plugin entries the patch omits are preserved;
- for app group lifecycle plugins, per-plugin configuration/status keys the patch
omits are preserved too, so a partial configuration edit does not wipe
plugin-managed status (e.g. a linked external group id).

group_updated fires only on configuration changes -- never on status-only changes,
which the plugin itself writes and which would otherwise re-trigger reconciliation in
a loop -- and only after the merge, so the hook observes the fully-merged plugin_data.
"""

def __init__(self, *, group: OktaGroup, plugin_data: dict[str, Any]):
self.group = group
self.plugin_data = plugin_data

def execute(self) -> OktaGroup:
old_plugin_data = self.group.plugin_data or {}
old_name = self.group.name
old_description = self.group.description or ""
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)

# Apply the patch: start from the incoming data, preserving top-level plugin
# entries it didn't mention.
merged_plugin_data = dict(self.plugin_data)
changed = bool(old_plugin_data) and merged_plugin_data != old_plugin_data
if changed:
for key in old_plugin_data:
if key not in merged_plugin_data:
merged_plugin_data[key] = old_plugin_data[key]

# Decide whether to fire the hook before the per-plugin merge below, which
# mutates old_plugin_data in place (it shares the configuration/status dicts)
# and would otherwise erase the difference we're testing for.
config_changed = plugin_id is not None and is_plugin_config_changed(
old_plugin_data, merged_plugin_data, plugin_id
)

self.group.plugin_data = merged_plugin_data

# Preserve per-plugin configuration/status keys the patch omitted for app group
# lifecycle plugins (partial-patch semantics).
if changed and type(self.group) is AppGroup:
merge_app_lifecycle_plugin_data(self.group, old_plugin_data)

db.session.commit()

if config_changed:
try:
hook = get_app_group_lifecycle_hook()
hook.group_updated(
session=db.session,
group=self.group,
old_name=old_name,
old_description=old_description,
plugin_id=plugin_id,
)
db.session.commit()
except Exception:
logging.getLogger("api").exception(
f"Failed to invoke group_updated hook for group {self.group.id} with plugin '{plugin_id}'"
)
db.session.rollback()

return self.group
Loading