Skip to content

OIDC group roles and Keycloak directory sync - #95

Open
dniasoff wants to merge 2 commits into
plannotator:mainfrom
orvexai:feat/oidc-group-roles
Open

dniasoff wants to merge 2 commits into
plannotator:mainfrom
orvexai:feat/oidc-group-roles

Conversation

@dniasoff

Copy link
Copy Markdown

Problem

With a generic OIDC issuer, access to a private installation is a hand-maintained member list: an administrator admits each person by email, assigns their role in Artifact Server, and must remember to deactivate leavers. Teams that already manage access with IdP groups (Keycloak, Authentik, Zitadel and others put a groups claim in tokens) cannot reuse those groups. People cannot be seen, shared with, or assigned work until each one has been admitted by hand.

Change

Two opt-in features. With none of the new settings set, behaviour and the rendered chart are unchanged, except that every chart pod now sets enableServiceLinks: false (see below).

1. Group roles at sign-in (commit "Admit OIDC members and set their roles from issuer groups")

  • Settings: ARTIFACT_SERVER_OIDC_MEMBER_GROUPS, ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS (comma lists, exact match) and ARTIFACT_SERVER_OIDC_GROUPS_CLAIM (default groups). Chart values: identity.oidcMemberGroups, identity.oidcAdministratorGroups, identity.oidcGroupsClaim.
  • How the role is decided:
    • an administrator group gives administrator; a member group gives member;
    • anyone in neither is denied, and any existing membership is deactivated.
  • When it applies: at browser sign-in and on every OIDC MCP access token, so a removal takes effect on the person's next call. When the policy is on, the bootstrap-email rule is not used.
  • Edge cases:
    • someone who rejoins a group is reactivated on their existing record;
    • a last administrator who loses their groups is demoted before being deactivated;
    • a groups claim that is not a list of strings is a provider failure.

2. Directory sync, so members exist before they first sign in (commit "Sync OIDC members from a Keycloak directory before they sign in")

  • artifactserver maintenance sync-directory --once reads the configured groups from the Keycloak admin REST API (<base>/admin/realms/<realm>, derived from the issuer). It authenticates with a client-credentials service account that needs only realm-management view-users + query-groups.
  • It applies the same domain rules directly through the identity repository (no HTTP admin API, no API key): admit, reactivate, change role, and deactivate members who are no longer in any group.
  • Every configured group is read in full before anything is written. An unknown group, a token or HTTP error, or zero eligible users aborts the run with nothing changed.
  • It prints one JSON summary line.
  • Settings: ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER=keycloak, ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID, and ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET[_FILE].
  • Chart: identity.oidcDirectoryClientId plus secret.keys.oidcDirectoryClientSecret enable a <release>-sync-directory CronJob (directorySync.schedule, default every 10 minutes), which is counted in postgresConnectionBudget.

Also in the chart: enableServiceLinks: false on every pod. With the release named artifact-server, Kubernetes service links inject ARTIFACT_SERVER_PORT=tcp://<ip>:80, which the CLI reads as its port, so the cleanup CronJob failed on every run. This is the one rendering change with no new values set.

Tests

  • Unit tests for role decisions and config parsing.
  • New conformance tests:
    • browser sign-in group roles;
    • MCP group roles: member, admin, denied and deactivated, promotion and demotion on a later token, malformed claim, policy off;
    • directory sync against a stub Keycloak over real HTTP: pagination, admin beats member, both directions of role change, reactivation, leaver, every abort-before-write case, disabled and unverified users, and a pre-created member binding at first sign-in.
  • CLI tests and Helm verify cases.
  • Locally: lint and typecheck clean; the full suite 356/356; the Cloudflare suite 37/37; the Postgres external-storage integration 19/19, including the compiled CLI sync against real Postgres; conformance ID check valid.

Live verification: we run this branch, as an orvex fork build, against Keycloak 26.5 with CNPG Postgres. The scheduled sync admitted two not-yet-signed-in people with the correct roles. A probe user was then admitted, reactivated, promoted, demoted and deactivated as its Keycloak group memberships changed.

Deployment effects

There are no migrations. The new Postgres access is only UPDATE installation_members SET role=…, through the new setMemberRole repository method, which is also implemented for SQLite and D1.

Depends on nothing else. #94 (OAuth bearer on upload PUT) is independent.

A self-hosted installation on a generic OIDC issuer can now take admission
and roles from the issuer's group claim instead of manual membership.
The policy is opt-in and off unless a group list is configured; with none
of the new settings, behaviour and rendered chart output are unchanged.

Settings (env, Helm identity.* values):
- ARTIFACT_SERVER_OIDC_MEMBER_GROUPS / identity.oidcMemberGroups
- ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS / identity.oidcAdministratorGroups
- ARTIFACT_SERVER_OIDC_GROUPS_CLAIM / identity.oidcGroupsClaim (default
  "groups")

Lists are comma-separated, trimmed, and compared exactly. They require the
OIDC issuer and client; a claim name without a list, or a list naming no
group, is refused. `config check` reports the policy as
configuration.oidcGroupRoles.

ExternalIdentity gains groups (null when the provider reports none). The
browser provider reads the claim from the verified ID token; the MCP
verifier reads it from the access token, or from userinfo when it falls
back there. A claim that is not a list of strings is a provider failure.
WorkOS identities report null.

With the policy on, every sign-in and every MCP token is evaluated:
an administrator group grants administrator, else a member group grants
member, else the person is refused and an active member is deactivated
(demoted first, so the issuer can remove the last administrator). Role
changes apply at once, a previously deactivated member is restored rather
than duplicated, and the bootstrap-email rule does not apply. MCP OAuth no
longer short-circuits through the stored subject binding under the policy.

IdentityRepository gains setMemberRole, implemented for SQLite, Postgres,
and Cloudflare D1.
Login-time group roles only act when someone signs in. This adds a
directory sync so members exist before their first sign-in and leavers
are deactivated without waiting for them to return.

New command: `artifactserver maintenance sync-directory --once`
(compact or external-storage, like cleanup-staging). It writes through
InstallationAccessService.reconcileDirectoryMembers on the identity
repository, never the HTTP admin API or API keys, and prints one JSON
line {groups, directoryUsers, admitted, reactivated, roleChanged,
deactivated, unchanged, skipped}; any failure exits non-zero.

One pass reads every configured group completely (exact-name group
search, then paged briefRepresentation members, 100 per page until a
short page) with a client-credentials token from the issuer's discovered
token endpoint, then reconciles: eligible users (enabled, verified,
email) get administrator if in an administrator group, else member;
missing ones are admitted, deactivated ones restored, roles changed, and
every other active member deactivated (demoted first, as at login).
An unknown group, token/HTTP/page error, or a directory with no eligible
user aborts before any write. Synced members bind to their OIDC subject
at first sign-in through the existing verified-email path.

Settings (valid only with OIDC group roles; issuer must be
<base>/realms/<realm>, admin API <base>/admin/realms/<realm>):
- ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER=keycloak
- ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID
- ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET / _FILE
`config check` reports them as configuration.oidcDirectory.

Helm: identity.oidcDirectoryProvider (default keycloak),
identity.oidcDirectoryClientId, secret.keys.oidcDirectoryClientSecret
(mounted as a file), and a directorySync block (schedule "*/10 * * * *",
activeDeadlineSeconds, backoffLimit). Setting the client ID adds the
<release>-sync-directory CronJob (concurrencyPolicy Forbid, same image,
service account, security, env, and Secret mounts as cleanup-staging)
and counts one more connection in the postgresConnectionBudget check.
Directory values require the OIDC issuer, client, and group lists; the
client ID requires its secret key.

Intended rendering change: every pod spec (Deployment, migration Job,
cleanup CronJob, directory sync CronJob, test pod) now sets
enableServiceLinks: false. With the release named artifact-server,
Kubernetes injected ARTIFACT_SERVER_PORT=tcp://... from the Service,
which the CLI reads as --port, so cleanup-staging failed every run.
Without the new values this is the only difference from v0.1.2 output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant