diff --git a/deploy/cloudflare/src/d1-identity-repository.ts b/deploy/cloudflare/src/d1-identity-repository.ts index 3a67935b..57ed42a0 100644 --- a/deploy/cloudflare/src/d1-identity-repository.ts +++ b/deploy/cloudflare/src/d1-identity-repository.ts @@ -519,6 +519,22 @@ export function createD1IdentityRepository( } return withoutSecretDigest(replacement); }, + + setMemberRole: async (installationId, memberId, role, updatedAt) => { + const result = await database.prepare(` + UPDATE installation_members + SET role = ?, status = ?, updated_at = ? + WHERE installation_id = ? AND id = ? + `).bind(role, memberStatuses.active, updatedAt, installationId, memberId) + .run(); + const member = result.meta.changes === 1 + ? await findMember(installationId, memberId) + : null; + if (member === null) { + throw new IdentityNotFound({message: "The member does not exist."}); + } + return member; + }, }; } diff --git a/docs/deployment.md b/docs/deployment.md index 873fcbdb..b8230796 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -91,6 +91,127 @@ The server reads the issuer's discovery document once at startup. If the issuer cannot be reached then, the server logs a warning and starts with browser login and API keys only. MCP OAuth stays off until the next restart. +### Manage access with issuer groups + +By default an administrator admits each person by hand, and only the bootstrap +administrator email signs in to an empty installation. A generic OIDC +installation can instead take admission and roles from the issuer's group claim, +so access is managed where people already are: + +| Setting | Helm value | Meaning | +| --- | --- | --- | +| `ARTIFACT_SERVER_OIDC_MEMBER_GROUPS` | `identity.oidcMemberGroups` | Groups whose people are admitted as members. | +| `ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS` | `identity.oidcAdministratorGroups` | Groups whose people are admitted as administrators. | +| `ARTIFACT_SERVER_OIDC_GROUPS_CLAIM` | `identity.oidcGroupsClaim` | The claim that lists a person's groups. Defaults to `groups`. | + +The environment variables take comma-separated lists; the Helm values take YAML +lists. Names are trimmed and compared exactly, including case. The policy is on +when either list names a group, and both lists require the OIDC issuer and +client. With none of these set, nothing changes. + +While the policy is on, every browser sign-in and every MCP access token is +checked against it. The email must still be verified. Then: + +- a person in an administrator group is an administrator; +- otherwise, a person in a member group is a member; +- otherwise the person is refused, and an existing member is deactivated, which + also ends their sessions and revokes their personal API keys. + +A role that differs from the current one is changed at once, so removing someone +from the administrator group demotes them on their next sign-in or MCP call. +Someone deactivated this way returns as the same member when they rejoin a +group. The bootstrap administrator email admits no one under the policy, and the +issuer is authoritative: removing the last administrator from the administrator +group leaves the installation without one until a member of that group signs +in. An open browser session follows its member's current role, but the issuer +is consulted only at sign-in and on MCP calls, so a group change reaches that +session through the person's next sign-in or MCP call. + +The browser reads the claim from the ID token. MCP reads it from the access +token, or from the userinfo response when the token carries no email. A claim +that is present but is not a list of strings fails the sign-in as a provider +error. Because MCP checks groups on every call, give the access token both the +email and the groups claim; a lean token sends each call to userinfo. + +In Keycloak: + +1. Create the groups, for example `artifact-users` and `artifact-admins`, and + add people to them. +2. In the client scope the Artifact Server client uses (the dedicated scope is + fine), add a mapper of type **Group Membership** with token claim name + `groups`, **Full group path** off, and **Add to ID token**, + **Add to access token**, and **Add to userinfo** on. With the full path on, + the claim says `/artifact-admins` and must be configured that way. +3. Configure the server: + + ```sh + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS=artifact-users + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS=artifact-admins + ``` + +`artifactserver config check` reports the active policy under +`configuration.oidcGroupRoles`. + +### Sync members from a Keycloak directory + +Group roles act when someone signs in. To have members exist before their first +sign-in, and to remove leavers without waiting for them to come back, also sync +the Keycloak directory: + +```sh +artifactserver maintenance sync-directory --once --mode external-storage +``` + +One pass reads every configured group completely, then makes the installation's +human members match it: + +- each enabled user with a verified email in an administrator group becomes an + administrator, and in a member group a member; +- missing people are admitted, deactivated ones are restored, and roles are + changed where they differ; +- every other active member is deactivated, which ends their sessions and + revokes their personal API keys. Service API keys are not touched. + +The pass reads the whole directory before it writes anything. A configured group +that does not exist, a token or HTTP error, a failed page, or a directory with +no eligible user stops the pass with a non-zero exit and changes nothing. A +successful pass prints one JSON line: +`{"groups":2,"directoryUsers":40,"admitted":1,"reactivated":0,"roleChanged":0,"deactivated":0,"unchanged":39,"skipped":0}`. +Display names come from first and last name, else the username, else the email; +an existing member keeps its display name. Synced members bind to their OIDC +subject at their first sign-in, by verified email, exactly as other members do. + +| Setting | Helm value | Meaning | +| --- | --- | --- | +| `ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER` | `identity.oidcDirectoryProvider` | `keycloak`, the only provider. The chart defaults it. | +| `ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID` | `identity.oidcDirectoryClientId` | The confidential client the sync authenticates as. | +| `ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET` or `_FILE` | `secret.keys.oidcDirectoryClientSecret` | Its client secret. | + +These settings require the group lists above, and the issuer must have the form +`/realms/`; the admin API is then `/admin/realms/`. +The sync obtains a token with the client-credentials grant at the issuer's +discovered token endpoint. + +In Keycloak, create a separate client for the sync: + +1. Create an OpenID Connect client such as `artifact-server-directory` with + **Client authentication** on and **Service accounts roles** on. Turn off the + standard and direct-access flows; it never signs anyone in. +2. Under **Service account roles**, assign only the `realm-management` client + roles `view-users` and `query-groups`. +3. Store the client secret in the Kubernetes Secret and name its key in + `secret.keys.oidcDirectoryClientSecret`. + +The configured groups are looked up by exact name among top-level groups. + +The Helm chart enables the `-sync-directory` CronJob as soon as +`identity.oidcDirectoryClientId` is set. It runs every ten minutes by default +(`directorySync.schedule`), never overlaps itself, and uses the same image, +service account, security context, environment, and Secret mounts as the +staging cleanup. It holds one Postgres connection while it runs, so raise +`configuration.postgresConnectionBudget` by one. `config check` reports the +directory under `configuration.oidcDirectory`. + ## Back up the installation Back up metadata and artifact files as one coordinated recovery set. Use the procedure in the selected deployment guide. diff --git a/packaging/compose/.env.example b/packaging/compose/.env.example index 6b5bd8aa..b474df31 100644 --- a/packaging/compose/.env.example +++ b/packaging/compose/.env.example @@ -37,3 +37,12 @@ ARTIFACT_SERVER_OIDC_CLIENT_ID=artifact-server # ARTIFACT_SERVER_OIDC_CLIENT_SECRET=replace_me # ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc-client-secret # ARTIFACT_SERVER_OIDC_SCOPES=openid email profile +# Optional: admit people and set their role from the issuer's groups claim. +# ARTIFACT_SERVER_OIDC_MEMBER_GROUPS=artifact-users +# ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS=artifact-admins +# ARTIFACT_SERVER_OIDC_GROUPS_CLAIM=groups +# Optional: let `artifactserver maintenance sync-directory --once` admit the +# Keycloak group members before they first sign in. +# ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER=keycloak +# ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID=artifact-server-directory +# ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET_FILE=/run/secrets/oidc-directory-client-secret diff --git a/packaging/compose/compose.external-storage.yaml b/packaging/compose/compose.external-storage.yaml index 9b9644d6..183f1021 100644 --- a/packaging/compose/compose.external-storage.yaml +++ b/packaging/compose/compose.external-storage.yaml @@ -14,9 +14,16 @@ services: - ARTIFACT_SERVER_DATABASE_URL_FILE=/run/secrets/artifact-server-database-url - ARTIFACT_SERVER_INSTALLATION_ID=${ARTIFACT_SERVER_INSTALLATION_ID:?Set ARTIFACT_SERVER_INSTALLATION_ID} - ARTIFACT_SERVER_OBJECT_STORAGE_PROVIDER=s3 + - ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS - ARTIFACT_SERVER_OIDC_CLIENT_ID - ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE + - ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID + - ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET + - ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET_FILE + - ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER + - ARTIFACT_SERVER_OIDC_GROUPS_CLAIM - ARTIFACT_SERVER_OIDC_ISSUER + - ARTIFACT_SERVER_OIDC_MEMBER_GROUPS - ARTIFACT_SERVER_OIDC_SCOPES - ARTIFACT_SERVER_ORIGIN=${ARTIFACT_SERVER_ORIGIN:?Set ARTIFACT_SERVER_ORIGIN} - ARTIFACT_SERVER_POSTGRES_MAX_CONNECTIONS=${ARTIFACT_SERVER_POSTGRES_MAX_CONNECTIONS:-10} diff --git a/packaging/compose/compose.yaml b/packaging/compose/compose.yaml index 76f20945..a764319b 100644 --- a/packaging/compose/compose.yaml +++ b/packaging/compose/compose.yaml @@ -12,10 +12,17 @@ services: - "8787" environment: - ARTIFACT_SERVER_CONTENT_DOMAIN=${ARTIFACT_SERVER_CONTENT_DOMAIN:?Set the separate wildcard content domain} + - ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS - ARTIFACT_SERVER_OIDC_CLIENT_ID - ARTIFACT_SERVER_OIDC_CLIENT_SECRET - ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE + - ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID + - ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET + - ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET_FILE + - ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER + - ARTIFACT_SERVER_OIDC_GROUPS_CLAIM - ARTIFACT_SERVER_OIDC_ISSUER + - ARTIFACT_SERVER_OIDC_MEMBER_GROUPS - ARTIFACT_SERVER_OIDC_SCOPES - ARTIFACT_SERVER_ORIGIN=${ARTIFACT_SERVER_ORIGIN:?Set the HTTPS application origin} - ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=${ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS:-1000} diff --git a/packaging/helm/artifact-server/README.md b/packaging/helm/artifact-server/README.md index db6b055d..ab1ad3a8 100644 --- a/packaging/helm/artifact-server/README.md +++ b/packaging/helm/artifact-server/README.md @@ -141,6 +141,56 @@ who obtained it. Such a token must name `configuration.applicationOrigin` followed by `/mcp` in `aud`, which the provider produces from an audience mapper or an RFC 8707 resource indicator. +Access can also follow the issuer's groups instead of manual admission. Set +`identity.oidcMemberGroups`, `identity.oidcAdministratorGroups`, or both to +lists of exact group names, and `identity.oidcGroupsClaim` when the claim is not +called `groups`: + +```yaml +identity: + oidcClientId: artifact-server + oidcIssuer: https://idp.example.com/realms/main + oidcMemberGroups: [artifact-users] + oidcAdministratorGroups: [artifact-admins] +``` + +Every sign-in and every MCP token then admits the person with the role their +groups grant, changes that role when the groups change, and deactivates the +member when no listed group remains. The values are unset by default, and the +chart rejects them without an OIDC issuer and client, a claim name without a +group list, and group names containing commas. See the deployment guide for the +issuer side. + +With Keycloak, a directory sync can also admit group members before they ever +sign in, and deactivate leavers without waiting for them to return. Set +`identity.oidcDirectoryClientId` to a confidential client with service accounts +on, and `secret.keys.oidcDirectoryClientSecret` to the Secret key holding its +client secret. `identity.oidcDirectoryProvider` defaults to `keycloak`, the only +provider. The chart then adds the `-sync-directory` CronJob, which runs +`maintenance sync-directory --once` on `directorySync.schedule` (every ten +minutes by default) with `concurrencyPolicy: Forbid`, and counts one more +Postgres connection against `configuration.postgresConnectionBudget`. The chart +rejects directory values without the OIDC issuer, client, and group lists, and a +client ID without its secret key. + +```yaml +identity: + oidcIssuer: https://idp.example.com/realms/main + oidcClientId: artifact-server + oidcMemberGroups: [artifact-users] + oidcAdministratorGroups: [artifact-admins] + oidcDirectoryClientId: artifact-server-directory +secret: + keys: + oidcDirectoryClientSecret: oidc-directory-client-secret +configuration: + postgresConnectionBudget: 23 +``` + +Every pod sets `enableServiceLinks: false`. A release named `artifact-server` +would otherwise receive `ARTIFACT_SERVER_PORT=tcp://...` from its own Service, +which the server reads as its listening port. + One installation has one browser-login provider. The chart rejects values that configure neither provider or configure WorkOS and OIDC together. diff --git a/packaging/helm/artifact-server/templates/_helpers.tpl b/packaging/helm/artifact-server/templates/_helpers.tpl index bb50b641..f6f8bbd8 100644 --- a/packaging/helm/artifact-server/templates/_helpers.tpl +++ b/packaging/helm/artifact-server/templates/_helpers.tpl @@ -74,6 +74,23 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} {{- if and (not $hasOidcIssuer) (or (ne .Values.identity.oidcScopes "") (ne .Values.secret.keys.oidcClientSecret "")) -}} {{- fail "identity.oidcScopes and secret.keys.oidcClientSecret require identity.oidcClientId and identity.oidcIssuer" -}} {{- end -}} +{{- $oidcGroupsClaim := default "" .Values.identity.oidcGroupsClaim -}} +{{- $hasOidcGroups := or (gt (len (default (list) .Values.identity.oidcMemberGroups)) 0) (gt (len (default (list) .Values.identity.oidcAdministratorGroups)) 0) -}} +{{- if and (not $hasOidcIssuer) (or $hasOidcGroups (ne $oidcGroupsClaim "")) -}} +{{- fail "identity.oidcGroupsClaim, identity.oidcMemberGroups, and identity.oidcAdministratorGroups require identity.oidcClientId and identity.oidcIssuer" -}} +{{- end -}} +{{- if and (ne $oidcGroupsClaim "") (not $hasOidcGroups) -}} +{{- fail "identity.oidcGroupsClaim requires identity.oidcMemberGroups or identity.oidcAdministratorGroups" -}} +{{- end -}} +{{- $oidcDirectoryClientId := default "" .Values.identity.oidcDirectoryClientId -}} +{{- $oidcDirectorySecret := default "" .Values.secret.keys.oidcDirectoryClientSecret -}} +{{- $hasOidcDirectory := or (ne $oidcDirectoryClientId "") (ne $oidcDirectorySecret "") (ne (default "" .Values.identity.oidcDirectoryProvider) "") -}} +{{- if and $hasOidcDirectory (or (not $hasOidcIssuer) (not $hasOidcGroups)) -}} +{{- fail "identity.oidcDirectoryProvider, identity.oidcDirectoryClientId, and secret.keys.oidcDirectoryClientSecret require identity.oidcClientId, identity.oidcIssuer, and identity.oidcMemberGroups or identity.oidcAdministratorGroups" -}} +{{- end -}} +{{- if and $hasOidcDirectory (or (eq $oidcDirectoryClientId "") (eq $oidcDirectorySecret "")) -}} +{{- fail "identity.oidcDirectoryClientId and secret.keys.oidcDirectoryClientSecret must be configured together" -}} +{{- end -}} {{- if and $hasOidcIssuer $hasWorkosIssuer -}} {{- fail "one installation has one browser-login provider: configure the identity.workos values or the identity.oidc values, not both" -}} {{- end -}} @@ -86,9 +103,10 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end -}} {{- $migrationConnections := ternary 1 0 .Values.migration.enabled -}} {{- $cleanupConnections := ternary 1 0 .Values.cleanup.enabled -}} -{{- $requiredConnections := add (mul (int .Values.replicaCount) (int .Values.configuration.postgresPoolSize)) $migrationConnections $cleanupConnections -}} +{{- $directorySyncConnections := ternary 1 0 (ne $oidcDirectoryClientId "") -}} +{{- $requiredConnections := add (mul (int .Values.replicaCount) (int .Values.configuration.postgresPoolSize)) $migrationConnections $cleanupConnections $directorySyncConnections -}} {{- if lt (int .Values.configuration.postgresConnectionBudget) $requiredConnections -}} -{{- fail (printf "configuration.postgresConnectionBudget must be at least %d for %d replicas and the enabled migration and cleanup Jobs" $requiredConnections (int .Values.replicaCount)) -}} +{{- fail (printf "configuration.postgresConnectionBudget must be at least %d for %d replicas and the enabled migration, cleanup, and directory sync Jobs" $requiredConnections (int .Values.replicaCount)) -}} {{- end -}} {{- range $key := keys .Values.podLabels -}} {{- if or (hasPrefix "app.kubernetes.io/" $key) (hasPrefix "helm.sh/" $key) (hasPrefix "artifactserver.com/" $key) -}} @@ -136,6 +154,15 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} - key: {{ .Values.secret.keys.oidcClientSecret | quote }} path: oidc-client-secret {{- end }} +{{- if .Values.secret.keys.oidcDirectoryClientSecret }} +- key: {{ .Values.secret.keys.oidcDirectoryClientSecret | quote }} + path: oidc-directory-client-secret +{{- end }} +{{- end -}} + +{{/* Non-empty when the Keycloak directory sync CronJob should exist. */}} +{{- define "artifact-server.directorySyncEnabled" -}} +{{- if .Values.identity.oidcDirectoryClientId -}}true{{- end -}} {{- end -}} {{- define "artifact-server.runtimeEnvironment" -}} @@ -198,6 +225,26 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} - name: ARTIFACT_SERVER_OIDC_SCOPES value: {{ .Values.identity.oidcScopes | quote }} {{- end }} +{{- if .Values.identity.oidcGroupsClaim }} +- name: ARTIFACT_SERVER_OIDC_GROUPS_CLAIM + value: {{ .Values.identity.oidcGroupsClaim | quote }} +{{- end }} +{{- if .Values.identity.oidcMemberGroups }} +- name: ARTIFACT_SERVER_OIDC_MEMBER_GROUPS + value: {{ join "," .Values.identity.oidcMemberGroups | quote }} +{{- end }} +{{- if .Values.identity.oidcAdministratorGroups }} +- name: ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS + value: {{ join "," .Values.identity.oidcAdministratorGroups | quote }} +{{- end }} +{{- if .Values.identity.oidcDirectoryClientId }} +- name: ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER + value: {{ default "keycloak" .Values.identity.oidcDirectoryProvider | quote }} +- name: ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID + value: {{ .Values.identity.oidcDirectoryClientId | quote }} +- name: ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET_FILE + value: /run/secrets/artifact-server/oidc-directory-client-secret +{{- end }} {{- end }} {{- if .Values.identity.workosClientId }} - name: ARTIFACT_SERVER_WORKOS_API_KEY_FILE diff --git a/packaging/helm/artifact-server/templates/cleanup-cronjob.yaml b/packaging/helm/artifact-server/templates/cleanup-cronjob.yaml index 0ea97d03..dd76b803 100644 --- a/packaging/helm/artifact-server/templates/cleanup-cronjob.yaml +++ b/packaging/helm/artifact-server/templates/cleanup-cronjob.yaml @@ -25,6 +25,7 @@ spec: restartPolicy: Never serviceAccountName: {{ include "artifact-server.serviceAccountName" . }} automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + enableServiceLinks: false securityContext: {{- toYaml .Values.podSecurityContext | nindent 12 }} {{- with .Values.imagePullSecrets }} diff --git a/packaging/helm/artifact-server/templates/deployment.yaml b/packaging/helm/artifact-server/templates/deployment.yaml index 4a3f93c1..c878faa4 100644 --- a/packaging/helm/artifact-server/templates/deployment.yaml +++ b/packaging/helm/artifact-server/templates/deployment.yaml @@ -35,6 +35,7 @@ spec: spec: serviceAccountName: {{ include "artifact-server.serviceAccountName" . }} automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + enableServiceLinks: false terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} diff --git a/packaging/helm/artifact-server/templates/directory-sync-cronjob.yaml b/packaging/helm/artifact-server/templates/directory-sync-cronjob.yaml new file mode 100644 index 00000000..2686c922 --- /dev/null +++ b/packaging/helm/artifact-server/templates/directory-sync-cronjob.yaml @@ -0,0 +1,68 @@ +{{- include "artifact-server.validation" . -}} +{{- if (include "artifact-server.directorySyncEnabled" .) }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "artifact-server.fullname" . }}-sync-directory + labels: + {{- include "artifact-server.labels" . | nindent 4 }} + app.kubernetes.io/component: directory-sync +spec: + schedule: {{ .Values.directorySync.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: {{ .Values.directorySync.activeDeadlineSeconds }} + backoffLimit: {{ .Values.directorySync.backoffLimit }} + template: + metadata: + labels: + {{- include "artifact-server.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: directory-sync + spec: + restartPolicy: Never + serviceAccountName: {{ include "artifact-server.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + enableServiceLinks: false + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 12 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + containers: + - name: sync-directory + image: {{ include "artifact-server.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - maintenance + - sync-directory + - --once + - --mode + - external-storage + env: + {{- include "artifact-server.runtimeEnvironment" . | nindent 16 }} + resources: + {{- toYaml .Values.resources | nindent 16 }} + securityContext: + {{- toYaml .Values.securityContext | nindent 16 }} + volumeMounts: + - name: runtime-secrets + mountPath: /run/secrets/artifact-server + readOnly: true + - name: temporary + mountPath: /tmp + volumes: + - name: runtime-secrets + secret: + secretName: {{ .Values.secret.name | quote }} + defaultMode: 288 + items: + {{- include "artifact-server.secretItems" . | nindent 18 }} + - name: temporary + emptyDir: + medium: Memory + sizeLimit: 64Mi +{{- end }} diff --git a/packaging/helm/artifact-server/templates/migration-job.yaml b/packaging/helm/artifact-server/templates/migration-job.yaml index 526bdf60..5f904f3c 100644 --- a/packaging/helm/artifact-server/templates/migration-job.yaml +++ b/packaging/helm/artifact-server/templates/migration-job.yaml @@ -22,6 +22,7 @@ spec: spec: restartPolicy: Never automountServiceAccountToken: false + enableServiceLinks: false securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} {{- with .Values.imagePullSecrets }} diff --git a/packaging/helm/artifact-server/templates/tests/ready.yaml b/packaging/helm/artifact-server/templates/tests/ready.yaml index 0af4255b..b9103266 100644 --- a/packaging/helm/artifact-server/templates/tests/ready.yaml +++ b/packaging/helm/artifact-server/templates/tests/ready.yaml @@ -12,6 +12,7 @@ metadata: spec: restartPolicy: Never automountServiceAccountToken: false + enableServiceLinks: false securityContext: {{- toYaml .Values.podSecurityContext | nindent 4 }} containers: diff --git a/packaging/helm/artifact-server/values.schema.json b/packaging/helm/artifact-server/values.schema.json index 7f459499..a904607a 100644 --- a/packaging/helm/artifact-server/values.schema.json +++ b/packaging/helm/artifact-server/values.schema.json @@ -118,7 +118,8 @@ "s3AccessKeyId": {"type": "string"}, "s3SecretAccessKey": {"type": "string"}, "workosApiKey": {"type": "string"}, - "oidcClientSecret": {"type": "string"} + "oidcClientSecret": {"type": "string"}, + "oidcDirectoryClientSecret": {"type": "string"} } } } @@ -132,7 +133,20 @@ "workosIssuer": {"type": "string"}, "oidcClientId": {"type": "string"}, "oidcIssuer": {"type": "string"}, - "oidcScopes": {"type": "string"} + "oidcScopes": {"type": "string"}, + "oidcGroupsClaim": {"type": "string"}, + "oidcMemberGroups": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[^,]*[^,\\s][^,]*$"} + }, + "oidcAdministratorGroups": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[^,]*[^,\\s][^,]*$"} + }, + "oidcDirectoryProvider": {"enum": ["keycloak"]}, + "oidcDirectoryClientId": {"type": "string"} } }, "serviceAccount": { @@ -228,6 +242,16 @@ "backoffLimit": {"type": "integer", "minimum": 0, "maximum": 10} } }, + "directorySync": { + "type": "object", + "additionalProperties": false, + "required": ["schedule", "activeDeadlineSeconds", "backoffLimit"], + "properties": { + "schedule": {"type": "string", "minLength": 1}, + "activeDeadlineSeconds": {"type": "integer", "minimum": 10, "maximum": 3600}, + "backoffLimit": {"type": "integer", "minimum": 0, "maximum": 10} + } + }, "ingress": { "type": "object", "additionalProperties": false, diff --git a/packaging/helm/artifact-server/values.yaml b/packaging/helm/artifact-server/values.yaml index 258c6f86..4b6ab31b 100644 --- a/packaging/helm/artifact-server/values.yaml +++ b/packaging/helm/artifact-server/values.yaml @@ -37,6 +37,7 @@ secret: s3SecretAccessKey: "" workosApiKey: "" oidcClientSecret: "" + # oidcDirectoryClientSecret: oidc-directory-client-secret identity: workosClientId: "" @@ -44,6 +45,18 @@ identity: oidcClientId: "" oidcIssuer: "" oidcScopes: "" + # Opt-in issuer group roles, unset by default so admission stays manual. + # Either list admits people from the issuer's groups claim and sets their + # role on every sign-in. Group names match exactly. + # oidcGroupsClaim: groups + # oidcMemberGroups: [artifact-users] + # oidcAdministratorGroups: [artifact-admins] + # Opt-in Keycloak directory sync, which admits group members before they + # first sign in and needs the group roles above. Setting the client ID + # enables the directorySync CronJob; secret.keys.oidcDirectoryClientSecret + # must name its client secret. + # oidcDirectoryProvider: keycloak + # oidcDirectoryClientId: artifact-server-directory serviceAccount: create: true @@ -125,6 +138,13 @@ cleanup: activeDeadlineSeconds: 300 backoffLimit: 1 +# Runs `maintenance sync-directory --once` when identity.oidcDirectoryClientId +# is set. +directorySync: + schedule: "*/10 * * * *" + activeDeadlineSeconds: 300 + backoffLimit: 0 + ingress: enabled: false className: "" diff --git a/scripts/verify-helm-chart.sh b/scripts/verify-helm-chart.sh index 1063b7d4..9870a584 100755 --- a/scripts/verify-helm-chart.sh +++ b/scripts/verify-helm-chart.sh @@ -35,6 +35,37 @@ helm template artifact-server "$artifactserver_chart" \ --set ingress.applicationTlsSecretName=artifact-server-application-tls \ --set ingress.contentTlsSecretName=artifact-server-content-tls >/dev/null +artifactserver_group_roles=$(helm template artifact-server "$artifactserver_chart" \ + --kube-version 1.36.0 \ + "${artifactserver_values[@]}" \ + --set 'identity.oidcMemberGroups={artifact-users,engineering}' \ + --set 'identity.oidcAdministratorGroups={artifact-admins}') +if [[ "$artifactserver_group_roles" != *'value: "artifact-users,engineering"'* || + "$artifactserver_group_roles" != *'value: "artifact-admins"'* ]]; then + echo "Helm did not render the OIDC group-role settings." >&2 + exit 1 +fi + +artifactserver_directory_sync=$(helm template artifact-server "$artifactserver_chart" \ + --kube-version 1.36.0 \ + "${artifactserver_values[@]}" \ + --set identity.oidcIssuer=https://idp.example.test/realms/main \ + --set 'identity.oidcMemberGroups={artifact-users}' \ + --set identity.oidcDirectoryClientId=artifact-server-directory \ + --set secret.keys.oidcDirectoryClientSecret=oidc-directory-client-secret \ + --set configuration.postgresConnectionBudget=23) +if [[ "$artifactserver_directory_sync" != *"name: artifact-server-sync-directory"* || + "$artifactserver_directory_sync" != *"- sync-directory"* || + "$artifactserver_directory_sync" != *"path: oidc-directory-client-secret"* ]]; then + echo "Helm did not render the directory sync CronJob." >&2 + exit 1 +fi +if [[ "$(helm template artifact-server "$artifactserver_chart" --kube-version 1.36.0 \ + "${artifactserver_values[@]}")" == *"sync-directory"* ]]; then + echo "Helm rendered a directory sync CronJob without a directory client." >&2 + exit 1 +fi + expect_render_failure() { local artifactserver_expected=$1 shift @@ -75,6 +106,37 @@ expect_render_failure "identity.oidcScopes and secret.keys.oidcClientSecret requ --set identity.oidcIssuer= \ --set identity.oidcScopes="openid email profile" +expect_render_failure "identity.oidcAdministratorGroups require identity.oidcClientId and identity.oidcIssuer" \ + "${artifactserver_values[@]}" \ + --set identity.oidcClientId= \ + --set identity.oidcIssuer= \ + --set 'identity.oidcAdministratorGroups={artifact-admins}' + +expect_render_failure "identity.oidcGroupsClaim requires identity.oidcMemberGroups or identity.oidcAdministratorGroups" \ + "${artifactserver_values[@]}" \ + --set identity.oidcGroupsClaim=groups + +expect_render_failure "does not match pattern" \ + "${artifactserver_values[@]}" \ + --set 'identity.oidcMemberGroups={artifact-users\,engineering}' + +expect_render_failure "secret.keys.oidcDirectoryClientSecret require identity.oidcClientId, identity.oidcIssuer, and identity.oidcMemberGroups" \ + "${artifactserver_values[@]}" \ + --set identity.oidcDirectoryClientId=artifact-server-directory \ + --set secret.keys.oidcDirectoryClientSecret=oidc-directory-client-secret + +expect_render_failure "identity.oidcDirectoryClientId and secret.keys.oidcDirectoryClientSecret must be configured together" \ + "${artifactserver_values[@]}" \ + --set 'identity.oidcMemberGroups={artifact-users}' \ + --set identity.oidcDirectoryClientId=artifact-server-directory \ + --set configuration.postgresConnectionBudget=23 + +expect_render_failure "for 2 replicas and the enabled migration, cleanup, and directory sync Jobs" \ + "${artifactserver_values[@]}" \ + --set 'identity.oidcMemberGroups={artifact-users}' \ + --set identity.oidcDirectoryClientId=artifact-server-directory \ + --set secret.keys.oidcDirectoryClientSecret=oidc-directory-client-secret + expect_render_failure "one installation has one browser-login provider" \ "${artifactserver_values[@]}" \ --set identity.workosClientId=client_workos \ diff --git a/src/application/directory-sync.ts b/src/application/directory-sync.ts new file mode 100644 index 00000000..fd7024cd --- /dev/null +++ b/src/application/directory-sync.ts @@ -0,0 +1,142 @@ +import {Effect, Schema} from "effect"; + +import { + type GroupRolePolicy, + groupMembershipRole, +} from "../core/group-role-policy.js"; +import {membershipRoles} from "../core/identity.js"; +import { + type DirectoryMemberEntry, + InstallationAccessService, + normalizeMemberEmail, +} from "./installation-access.js"; + +/** One person a member directory lists in a group. */ +export interface DirectoryUser { + readonly email: string | null; + readonly emailVerified: boolean; + readonly enabled: boolean; + readonly firstName: string | null; + readonly id: string; + readonly lastName: string | null; + readonly username: string; +} + +/** The complete membership of one named directory group. */ +export interface DirectoryGroup { + readonly name: string; + readonly users: readonly DirectoryUser[]; +} + +/** The directory could not be read completely, so nothing may change. */ +export class DirectoryUnavailable extends Schema.TaggedError()( + "DirectoryUnavailable", + {message: Schema.String}, +) {} + +/** + * A member directory that reports the complete membership of named groups. + * It must fail rather than answer with a partial group. + */ +export interface MemberDirectory { + readonly listGroups: ( + names: readonly string[], + ) => Effect.Effect; +} + +/** The one-line outcome of one directory sync pass. */ +export interface DirectorySyncReport { + readonly admitted: number; + readonly deactivated: number; + readonly directoryUsers: number; + readonly groups: number; + readonly reactivated: number; + readonly roleChanged: number; + readonly skipped: number; + readonly unchanged: number; +} + +/** + * Read every configured group from the directory, then make the installation's + * human members match it. The directory is read completely before the first + * write, and a directory with no eligible user changes nothing. + */ +export const syncDirectoryMembers = Effect.fn("syncDirectoryMembers")(function*( + directory: MemberDirectory, + policy: GroupRolePolicy, +) { + const names = [...new Set([ + ...policy.administratorGroups, + ...policy.memberGroups, + ])]; + const groups = yield* directory.listGroups(names); + const groupsByUser = new Map(); + for (const group of groups) { + for (const user of group.users) { + const seen = groupsByUser.get(user.id); + if (seen === undefined) { + groupsByUser.set(user.id, {groups: [group.name], user}); + } else { + seen.groups.push(group.name); + } + } + } + const desired = new Map(); + let skipped = 0; + for (const {groups: userGroups, user} of groupsByUser.values()) { + const email = user.enabled && user.emailVerified && user.email !== null + ? normalizeMemberEmail(user.email) + : null; + const role = groupMembershipRole(policy, userGroups); + if (email === null || role === null) { + skipped += 1; + continue; + } + const previous = desired.get(email); + // Two directory users sharing one address are one member; the stronger + // role wins, as one person in both groups would. + if (previous === undefined || role === membershipRoles.administrator) { + desired.set(email, {displayName: displayName(user, email), email, role}); + } + } + if (desired.size === 0) { + return yield* new DirectoryUnavailable({ + message: + "The directory reported no enabled user with a verified email in the configured groups; nothing was changed.", + }); + } + const reconciliation = yield* InstallationAccessService.use((access) => + access.reconcileDirectoryMembers([...desired.values()]) + ); + // Field order is the order operators read the one-line summary in. + const report: DirectorySyncReport = { + groups: groups.length, + directoryUsers: groupsByUser.size, + admitted: reconciliation.admitted, + reactivated: reconciliation.reactivated, + roleChanged: reconciliation.roleChanged, + deactivated: reconciliation.deactivated, + unchanged: reconciliation.unchanged, + skipped, + }; + return report; +}); + +const displayNameLimit = 200; + +function displayName(user: DirectoryUser, email: string): string { + return preferredName(user, email).slice(0, displayNameLimit); +} + +function preferredName(user: DirectoryUser, email: string): string { + const name = [user.firstName, user.lastName] + .map((part) => part?.trim() ?? "") + .filter((part) => part !== "") + .join(" "); + if (name !== "") return name; + const username = user.username.trim(); + return username === "" ? email : username; +} diff --git a/src/application/installation-access.ts b/src/application/installation-access.ts index 2c9e4e8a..04a289f8 100644 --- a/src/application/installation-access.ts +++ b/src/application/installation-access.ts @@ -31,6 +31,10 @@ import { memberStatuses, type StoredManagedApiKey, } from "../core/installation-identity.js"; +import { + type GroupRolePolicy, + groupMembershipRole, +} from "../core/group-role-policy.js"; import { isHumanAdministrator, membershipRoles, @@ -147,6 +151,15 @@ export interface InstallationIdentityRepository { ManagedApiKey, IdentityConflict | IdentityNotFound | IdentityRepositoryFailure >; + readonly setMemberRole: ( + installationId: string, + memberId: string, + role: MembershipRole, + updatedAt: string, + ) => Effect.Effect< + InstallationMember, + IdentityNotFound | IdentityRepositoryFailure + >; } export interface IdentitySecretProvider { @@ -172,6 +185,12 @@ export interface InstallationAccessDependencies { readonly authenticationCache?: AuthenticationCachePolicy | null; readonly bootstrapAdministratorEmail: string; readonly clock: {readonly now: () => Date}; + /** + * Issuer groups that admit external identities and set their role. When + * present it replaces manual admission and the bootstrap-email rule for + * external identities; omit it or pass `null` to keep both. + */ + readonly groupRolePolicy?: GroupRolePolicy | null; readonly ids: IdentityIdProvider; readonly installationId: string; readonly localBootstrapCredential: Redacted.Redacted | null; @@ -190,6 +209,23 @@ export interface AdmitMemberCommand { readonly role: MembershipRole; } +/** One person a member directory says should be an active member. */ +export interface DirectoryMemberEntry { + readonly displayName: string; + /** Already normalized with {@link normalizeMemberEmail}. */ + readonly email: string; + readonly role: MembershipRole; +} + +/** What one directory reconciliation changed. */ +export interface DirectoryReconciliation { + readonly admitted: number; + readonly deactivated: number; + readonly reactivated: number; + readonly roleChanged: number; + readonly unchanged: number; +} + export interface IssueApiKeyCommand { readonly capabilities: readonly PrincipalCapability[]; readonly expiresAt: string; @@ -220,6 +256,10 @@ export interface InstallationAccessOperations { Principal, IdentityAdmissionDenied | IdentityConflict | IdentityRepositoryFailure >; + /** + * Recognize an already-bound external subject without its profile. Answers + * null under an issuer group policy, whose groups must be read every time. + */ readonly authenticateExternalSubject: ( provider: string, subject: string, @@ -280,6 +320,18 @@ export interface InstallationAccessOperations { IssuedApplicationSession, IdentityConflict | IdentityRepositoryFailure >; + /** + * Make the installation's human members exactly the given directory entries: + * admit, restore, or re-role each entry, and deactivate every other active + * member. Only an installation with issuer group roles may do this, and an + * empty entry list is refused so a failed read can never empty it. + */ + readonly reconcileDirectoryMembers: ( + entries: readonly DirectoryMemberEntry[], + ) => Effect.Effect< + DirectoryReconciliation, + IdentityConflict | IdentityRepositoryFailure + >; readonly revokeApiKey: ( principal: Principal, keyId: string, @@ -327,6 +379,13 @@ function makeInstallationAccessService( : new AuthenticationCache(cachePolicy); const cacheKey = (digest: string): string => `${dependencies.installationId}:${digest}`; + // Session entries are keyed by token digest, not member, so the rare + // membership mutations clear the whole cache instead of indexing it. + const clearAuthenticationCaches = (): void => { + sessionCache?.clear(); + apiKeyCache?.clear(); + }; + const groupRolePolicy = dependencies.groupRolePolicy ?? null; const issueSession = Effect.fn( "InstallationAccessService.issueSession", @@ -439,6 +498,178 @@ function makeInstallationAccessService( return yield* issueSession(member); }); + /** + * Deactivate one member the issuer no longer admits. Deactivation keeps a + * last administrator in place; the issuer has already taken that role away, + * so demote before deactivating. + */ + const deactivateRemovedMember = Effect.fn( + "InstallationAccessService.deactivateRemovedMember", + )(function*(member: InstallationMember, now: string) { + if (member.role !== membershipRoles.member) { + yield* dependencies.repository.setMemberRole( + dependencies.installationId, + member.id, + membershipRoles.member, + now, + ); + } + yield* dependencies.repository.deactivateMember( + dependencies.installationId, + member.id, + now, + ); + }); + + const reconcileDirectoryMembers = Effect.fn( + "InstallationAccessService.reconcileDirectoryMembers", + )(function*(entries: readonly DirectoryMemberEntry[]) { + if (groupRolePolicy === null) { + return yield* Effect.fail(new IdentityConflict({ + message: "Directory sync requires issuer group roles.", + })); + } + if (entries.length === 0) { + return yield* Effect.fail(new IdentityConflict({ + message: "Directory sync refuses an empty directory.", + })); + } + const desired = new Map(); + for (const entry of entries) { + if (normalizeMemberEmail(entry.email) !== entry.email) { + return yield* Effect.fail(new IdentityConflict({ + message: "Directory entries must carry normalized email addresses.", + })); + } + desired.set(entry.email, entry); + } + const now = dependencies.clock.now().toISOString(); + const members = yield* dependencies.repository.listMembers( + dependencies.installationId, + ); + const byEmail = new Map(members.map((member) => [member.email, member])); + let admitted = 0; + let reactivated = 0; + let roleChanged = 0; + let unchanged = 0; + // Admissions and promotions first, so removals never run ahead of them. + for (const entry of [...desired.values()].toSorted((left, right) => + left.email.localeCompare(right.email) + )) { + const member = byEmail.get(entry.email); + if (member === undefined) { + yield* dependencies.repository.admitMember({ + createdAt: now, + displayName: entry.displayName, + email: entry.email, + id: dependencies.ids.memberId(), + installationId: dependencies.installationId, + role: entry.role, + }); + admitted += 1; + } else if (member.status !== memberStatuses.active) { + yield* dependencies.repository.setMemberRole( + dependencies.installationId, + member.id, + entry.role, + now, + ); + reactivated += 1; + } else if (member.role !== entry.role) { + yield* dependencies.repository.setMemberRole( + dependencies.installationId, + member.id, + entry.role, + now, + ); + roleChanged += 1; + } else { + unchanged += 1; + } + } + let deactivated = 0; + for (const member of members) { + if (member.status !== memberStatuses.active || desired.has(member.email)) { + continue; + } + yield* deactivateRemovedMember(member, now); + deactivated += 1; + } + if (roleChanged + deactivated > 0) clearAuthenticationCaches(); + const reconciliation: DirectoryReconciliation = { + admitted, + deactivated, + reactivated, + roleChanged, + unchanged, + }; + return reconciliation; + }, Effect.catchTag("IdentityNotFound", () => + Effect.fail(new IdentityConflict({ + message: "A member changed while the directory was applied.", + })))); + + /** + * Make the member's state match the role its issuer groups grant: admit or + * restore it, change its role, or deactivate it and refuse admission. + */ + const applyGroupRole = Effect.fn( + "InstallationAccessService.applyGroupRole", + )(function*( + policy: GroupRolePolicy, + identity: ExternalIdentity, + email: string, + member: InstallationMember | null, + ) { + const role = groupMembershipRole(policy, identity.groups); + const now = dependencies.clock.now().toISOString(); + if (role === null) { + if (member !== null) { + yield* deactivateRemovedMember(member, now); + clearAuthenticationCaches(); + } + return yield* Effect.fail(new IdentityAdmissionDenied({ + message: "This person is not in a group admitted to the Artifact Server.", + })); + } + if (member !== null) { + if (member.role === role) return member; + const changed = yield* dependencies.repository.setMemberRole( + dependencies.installationId, + member.id, + role, + now, + ); + clearAuthenticationCaches(); + return changed; + } + // A member deactivated earlier keeps its email, so restore that member + // rather than admitting a second one under the same address. + const members = yield* dependencies.repository.listMembers( + dependencies.installationId, + ); + const previous = members.find((candidate) => candidate.email === email); + if (previous !== undefined) { + return yield* dependencies.repository.setMemberRole( + dependencies.installationId, + previous.id, + role, + now, + ); + } + return yield* dependencies.repository.admitMember({ + createdAt: now, + displayName: identity.displayName, + email, + id: dependencies.ids.memberId(), + installationId: dependencies.installationId, + role, + }); + }, Effect.catchTag("IdentityNotFound", () => + Effect.fail(new IdentityConflict({ + message: "The member changed while its groups were applied.", + })))); + const resolveExternalMember = Effect.fn( "InstallationAccessService.resolveExternalMember", )(function*(identity: ExternalIdentity) { @@ -459,7 +690,14 @@ function makeInstallationAccessService( email, ); } - if (member === null) { + if (groupRolePolicy !== null) { + member = yield* applyGroupRole( + groupRolePolicy, + identity, + email, + member, + ); + } else if (member === null) { const hasMembers = yield* dependencies.repository.hasMembers( dependencies.installationId, ); @@ -502,6 +740,7 @@ function makeInstallationAccessService( const authenticateExternalSubject = Effect.fn( "InstallationAccessService.authenticateExternalSubject", )(function*(provider: string, subject: string) { + if (groupRolePolicy !== null) return null; const member = yield* dependencies.repository.findActiveMemberByExternalIdentity( dependencies.installationId, provider, @@ -636,10 +875,7 @@ function makeInstallationAccessService( memberId, dependencies.clock.now().toISOString(), ); - // Session entries are keyed by token digest, not member, so this rare - // administrative mutation clears the whole cache instead of indexing it. - sessionCache?.clear(); - apiKeyCache?.clear(); + clearAuthenticationCaches(); return member; }); @@ -775,6 +1011,7 @@ function makeInstallationAccessService( listApiKeys, listMembers, loginWithLocalBrowserToken, + reconcileDirectoryMembers, revokeApiKey, revokeSession, rotateApiKey, @@ -811,13 +1048,19 @@ function invalidApiKey(): Effect.Effect { })); } +/** The stored form of one member email, or null when it is not an address. */ +export function normalizeMemberEmail(value: string): string | null { + const normalized = value.trim().toLocaleLowerCase("en-US"); + return normalized.length < 3 || normalized.length > 320 || + !normalized.includes("@") + ? null + : normalized; +} + const normalizeEmail = Effect.fn("InstallationAccessService.normalizeEmail")( function*(value: string): Effect.fn.Return { - const normalized = value.trim().toLocaleLowerCase("en-US"); - if ( - normalized.length < 3 || normalized.length > 320 || - !normalized.includes("@") - ) { + const normalized = normalizeMemberEmail(value); + if (normalized === null) { return yield* new IdentityConflict({ message: "A valid email address is required.", }); diff --git a/src/cli/lifecycle-commands.ts b/src/cli/lifecycle-commands.ts index 8f6e8cd9..af5d788a 100644 --- a/src/cli/lifecycle-commands.ts +++ b/src/cli/lifecycle-commands.ts @@ -19,6 +19,7 @@ import {createOidcIdentityProvider} from "../identity/oidc-identity-provider.js"; import {createWorkOsHostedAuthentication} from "../identity/workos-hosted-authentication.js"; +import {KeycloakDirectory} from "../identity/keycloak-directory.js"; import { checkCompactIntegrity, checkExternalStorageIntegrity, @@ -86,6 +87,10 @@ interface CleanupStagingOptions extends LifecycleOptions { readonly once: boolean; } +interface SyncDirectoryOptions extends LifecycleOptions { + readonly once: boolean; +} + /** Values injected from the release entrypoint into lifecycle commands. */ export interface LifecycleCommandOptions { readonly build: ProductBuildInformation; @@ -136,6 +141,55 @@ function configureMaintenance(program: Command): void { await runtime.close(); } }); + maintenance + .command("sync-directory") + .description( + "Make installation members match the OIDC issuer's directory groups.", + ) + .requiredOption("--once", "run one complete pass and exit") + .addOption(modeOption()) + .addOption(dataOption()) + .addOption(hostOption()) + .addOption(portOption()) + .action(async (options: SyncDirectoryOptions) => { + const configuration = await lifecycleConfiguration(options); + assertAtMostOneBrowserLoginProvider(process.env); + const oidc = await loadOidcConfiguration({ + ...process.env, + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: + configuration.bootstrapAdministratorEmail, + ARTIFACT_SERVER_ORIGIN: configuration.applicationOrigin, + }); + const groupRoles = oidc?.groupRoles ?? null; + const directorySettings = oidc?.directory ?? null; + if (oidc === null || groupRoles === null || directorySettings === null) { + throw new Error( + "maintenance sync-directory requires OIDC group roles (ARTIFACT_SERVER_OIDC_MEMBER_GROUPS or ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS) and the ARTIFACT_SERVER_OIDC_DIRECTORY_* settings.", + ); + } + const directory = new KeycloakDirectory({ + adminBaseUrl: directorySettings.adminBaseUrl, + clientId: directorySettings.clientId, + clientSecret: directorySettings.clientSecret, + issuer: oidc.issuer, + }); + const runtime = configuration.deploymentMode === "compact" + ? await createLocalRuntime({ + ...compactMaintenanceConfig(configuration), + groupRolePolicy: groupRoles, + }) + : await createExternalStorageRuntime({ + ...externalMaintenanceConfig(configuration), + groupRolePolicy: groupRoles, + }); + try { + console.log(JSON.stringify( + await runtime.syncDirectory(directory, groupRoles), + )); + } finally { + await runtime.close(); + } + }); } function configureInitialization(program: Command): void { @@ -429,7 +483,10 @@ async function startCompactServer( type OidcServerAuthentication = | OidcHostedAuthentication - | Pick; + | Pick< + OidcHostedAuthentication, + "groupRolePolicy" | "interactiveIdentityProvider" + >; /** MCP OAuth needs the issuer at startup; browser login must survive it being down. */ async function oidcAuthenticationOrBrowserOnly( @@ -442,7 +499,10 @@ async function oidcAuthenticationOrBrowserOnly( process.stderr.write( `OIDC configuration warning (discovery_failed): MCP OAuth stays off for ${oidc.issuer}: ${reason}\n`, ); - return {interactiveIdentityProvider: createOidcIdentityProvider(oidc)}; + return { + groupRolePolicy: oidc.groupRoles, + interactiveIdentityProvider: createOidcIdentityProvider(oidc), + }; } } @@ -536,6 +596,8 @@ async function summarizeConfiguration( return summarizeRuntimeConfiguration( configuration, workOs === null ? "oidc" : "workos", + oidc?.groupRoles ?? null, + oidc?.directory ?? null, ); } diff --git a/src/cli/oidc-configuration.ts b/src/cli/oidc-configuration.ts index 8fd42d8a..29a9b97e 100644 --- a/src/cli/oidc-configuration.ts +++ b/src/cli/oidc-configuration.ts @@ -1,7 +1,12 @@ import {type Redacted, Schema} from "effect"; import {z} from "zod"; +import { + defaultOidcGroupsClaim, + type OidcGroupRoles, +} from "../identity/oidc-groups-claim.js"; import {defaultOidcScopes} from "../identity/oidc-identity-provider.js"; +import {keycloakAdminBaseUrl} from "../identity/keycloak-directory.js"; import {requireOidcIssuer} from "../identity/oidc-issuer.js"; import { loadOptionalCredential, @@ -11,18 +16,36 @@ import {runCliEffect} from "./run-cli-effect.js"; const oidcEnvironmentSchema = z.object({ ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: z.email().optional(), + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: z.string().min(1).optional(), ARTIFACT_SERVER_OIDC_CLIENT_ID: z.string().min(1).optional(), + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID: z.string().min(1).optional(), + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: z.string().min(1).optional(), + ARTIFACT_SERVER_OIDC_GROUPS_CLAIM: z.string().trim().min(1).optional(), ARTIFACT_SERVER_OIDC_ISSUER: z.string().min(1).optional(), + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: z.string().min(1).optional(), ARTIFACT_SERVER_OIDC_SCOPES: z.string().min(1).optional(), ARTIFACT_SERVER_ORIGIN: z.url().optional(), }); +/** A directory that lists group members before they ever sign in. */ +export interface OidcDirectoryConfiguration { + /** Admin REST base derived from the realm issuer. */ + readonly adminBaseUrl: string; + readonly clientId: string; + readonly clientSecret: Redacted.Redacted; + readonly provider: "keycloak"; +} + /** Complete optional generic OIDC browser-login configuration. */ export interface OidcConfiguration { readonly applicationOrigin: string; readonly bootstrapAdministratorEmail: string; readonly clientId: string; readonly clientSecret: Redacted.Redacted | null; + /** Directory sync settings; null when the directory is not synced. */ + readonly directory: OidcDirectoryConfiguration | null; + /** Issuer groups that admit people and set their role; null keeps it manual. */ + readonly groupRoles: OidcGroupRoles | null; readonly issuer: string; readonly scopes: string; } @@ -37,11 +60,22 @@ export async function loadOidcConfiguration( "ARTIFACT_SERVER_OIDC_CLIENT_SECRET", Schema.NonEmptyString, )); + const directorySecret = await runCliEffect(loadOptionalCredential( + environment, + "ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET", + Schema.NonEmptyString, + )); if ( clientSecret === null && + directorySecret === null && + parsed.ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID === undefined && + parsed.ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER === undefined && parsed.ARTIFACT_SERVER_OIDC_CLIENT_ID === undefined && parsed.ARTIFACT_SERVER_OIDC_ISSUER === undefined && - parsed.ARTIFACT_SERVER_OIDC_SCOPES === undefined + parsed.ARTIFACT_SERVER_OIDC_SCOPES === undefined && + parsed.ARTIFACT_SERVER_OIDC_GROUPS_CLAIM === undefined && + parsed.ARTIFACT_SERVER_OIDC_MEMBER_GROUPS === undefined && + parsed.ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS === undefined ) { return null; } @@ -56,6 +90,11 @@ export async function loadOidcConfiguration( "Generic OIDC authentication requires ARTIFACT_SERVER_ORIGIN, ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL, ARTIFACT_SERVER_OIDC_ISSUER, and ARTIFACT_SERVER_OIDC_CLIENT_ID. ARTIFACT_SERVER_OIDC_CLIENT_SECRET or ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE and ARTIFACT_SERVER_OIDC_SCOPES are optional.", ); } + const issuer = requireOidcIssuer( + requireString(parsed.ARTIFACT_SERVER_OIDC_ISSUER), + "ARTIFACT_SERVER_OIDC_ISSUER", + ); + const groupRoles = oidcGroupRoles(parsed); return { applicationOrigin: requireString(parsed.ARTIFACT_SERVER_ORIGIN), bootstrapAdministratorEmail: requireString( @@ -63,10 +102,14 @@ export async function loadOidcConfiguration( ), clientId: requireString(parsed.ARTIFACT_SERVER_OIDC_CLIENT_ID), clientSecret: credentialOrNull(clientSecret), - issuer: requireOidcIssuer( - requireString(parsed.ARTIFACT_SERVER_OIDC_ISSUER), - "ARTIFACT_SERVER_OIDC_ISSUER", + directory: oidcDirectory( + parsed, + credentialOrNull(directorySecret), + issuer, + groupRoles, ), + groupRoles, + issuer, scopes: parsed.ARTIFACT_SERVER_OIDC_SCOPES ?? defaultOidcScopes, }; } @@ -101,6 +144,81 @@ function hasBrowserLoginVariable( ); } +/** Read the opt-in group roles; with no group list set, admission stays manual. */ +function oidcGroupRoles( + parsed: z.infer, +): OidcGroupRoles | null { + const memberGroups = groupNames( + "ARTIFACT_SERVER_OIDC_MEMBER_GROUPS", + parsed.ARTIFACT_SERVER_OIDC_MEMBER_GROUPS, + ); + const administratorGroups = groupNames( + "ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS", + parsed.ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS, + ); + if (memberGroups.length === 0 && administratorGroups.length === 0) { + if (parsed.ARTIFACT_SERVER_OIDC_GROUPS_CLAIM !== undefined) { + throw new Error( + "ARTIFACT_SERVER_OIDC_GROUPS_CLAIM requires ARTIFACT_SERVER_OIDC_MEMBER_GROUPS or ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS.", + ); + } + return null; + } + return { + administratorGroups, + claim: parsed.ARTIFACT_SERVER_OIDC_GROUPS_CLAIM ?? defaultOidcGroupsClaim, + memberGroups, + }; +} + +/** Read the opt-in directory sync, which only group roles can give meaning. */ +function oidcDirectory( + parsed: z.infer, + clientSecret: Redacted.Redacted | null, + issuer: string, + groupRoles: OidcGroupRoles | null, +): OidcDirectoryConfiguration | null { + const provider = parsed.ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER; + const clientId = parsed.ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID; + if (provider === undefined && clientId === undefined && clientSecret === null) { + return null; + } + if (groupRoles === null) { + throw new Error( + "The ARTIFACT_SERVER_OIDC_DIRECTORY_* settings require ARTIFACT_SERVER_OIDC_MEMBER_GROUPS or ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS.", + ); + } + if (provider === undefined || clientId === undefined || clientSecret === null) { + throw new Error( + "Directory sync requires ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER, ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID, and ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET or ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET_FILE.", + ); + } + if (provider !== "keycloak") { + throw new Error( + "ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER supports only keycloak.", + ); + } + const adminBaseUrl = keycloakAdminBaseUrl(issuer); + if (adminBaseUrl === null) { + throw new Error( + "Keycloak directory sync requires ARTIFACT_SERVER_OIDC_ISSUER in the form /realms/.", + ); + } + return {adminBaseUrl, clientId, clientSecret, provider}; +} + +/** Split one comma-separated list of exact, case-sensitive group names. */ +function groupNames(name: string, value: string | undefined): readonly string[] { + if (value === undefined) return []; + const groups = [...new Set( + value.split(",").map((group) => group.trim()).filter((group) => group !== ""), + )]; + if (groups.length === 0) { + throw new Error(`${name} must name at least one group.`); + } + return groups; +} + function credentialOrNull(value: LoadedSecret | null): Redacted.Redacted | null { return value === null ? null : value.value; } diff --git a/src/core/errors.ts b/src/core/errors.ts index 389f4c7f..e58528b5 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -447,6 +447,7 @@ export class IdentityRepositoryFailure extends Schema.TaggedError policy.administratorGroups.includes(group))) { + return membershipRoles.administrator; + } + if (groups.some((group) => policy.memberGroups.includes(group))) { + return membershipRoles.member; + } + return null; +} diff --git a/src/core/identity-ports.ts b/src/core/identity-ports.ts index 66d0c7d9..7114efe7 100644 --- a/src/core/identity-ports.ts +++ b/src/core/identity-ports.ts @@ -96,6 +96,16 @@ export interface IdentityRepository { replacement: StoredManagedApiKey, revokedAt: string, ): Promise; + /** + * Give one existing member a role and make it active. Issuer group policy is + * authoritative here, so this does not keep a last administrator in place. + */ + setMemberRole( + installationId: string, + memberId: string, + role: InstallationMember["role"], + updatedAt: string, + ): Promise; } /** Atomic first-key operation required by private-team composition roots. */ diff --git a/src/core/installation-identity.ts b/src/core/installation-identity.ts index e3572305..1b832c20 100644 --- a/src/core/installation-identity.ts +++ b/src/core/installation-identity.ts @@ -35,6 +35,8 @@ export interface ExternalIdentity { readonly displayName: string; readonly email: string; readonly emailVerified: boolean; + /** Issuer group names, or null when the provider did not report any. */ + readonly groups: readonly string[] | null; readonly provider: string; readonly subject: string; } diff --git a/src/external-storage/create-external-storage-runtime.ts b/src/external-storage/create-external-storage-runtime.ts index 9b993342..f3485f7e 100644 --- a/src/external-storage/create-external-storage-runtime.ts +++ b/src/external-storage/create-external-storage-runtime.ts @@ -8,6 +8,12 @@ import type { import type {InteractiveIdentityProvider} from "../application/interactive-login.js"; import type {Clock} from "../core/ports.js"; import type {BrowserAccess} from "../core/browser-access.js"; +import type {GroupRolePolicy} from "../core/group-role-policy.js"; +import type { + DirectorySyncReport, + MemberDirectory, +} from "../application/directory-sync.js"; +import {runDirectorySyncPass} from "../lifecycle/directory-sync.js"; import {SystemClock, SystemIdGenerator} from "../core/system.js"; import {createHttpApp} from "../http/create-http-app.js"; import type { @@ -83,6 +89,8 @@ export interface ExternalStorageRuntimeConfig { readonly gitHistoryHealthProbe?: GitHistoryProviderHealthProbe; /** Complete provider seam for conformance tests; production composes REST and Git. */ readonly gitHistoryProvider?: GitHistoryProvider; + /** Issuer groups that admit people and set their role, when configured. */ + readonly groupRolePolicy?: GroupRolePolicy | null; readonly installationId: string; readonly interactiveIdentityProvider?: InteractiveIdentityProvider; readonly localBootstrapCredential?: Redacted.Redacted; @@ -99,6 +107,11 @@ export interface ExternalStorageRuntime { readonly app: ReturnType; cleanupStaging(limit: number): Promise; close(): Promise; + /** Make the members match one directory under the given group roles. */ + syncDirectory( + directory: MemberDirectory, + policy: GroupRolePolicy, + ): Promise; } /** Connect external providers, validate migrations, verify readiness, and build the app. */ @@ -167,6 +180,7 @@ export async function createExternalStorageRuntime( externalApiBearerVerifier: config.externalApiBearerVerifier ?? null, externalMcpBearerVerifier: config.externalMcpBearerVerifier ?? null, externalMcpOAuthVerifier: config.externalMcpOAuthVerifier ?? null, + groupRolePolicy: config.groupRolePolicy ?? null, ids: new SystemIdGenerator(), identityRepository, installationId: config.installationId, @@ -258,6 +272,8 @@ export async function createExternalStorageRuntime( ? appDependencies : {...appDependencies, runtimeLifecycle: config.runtimeLifecycle}), cleanupStaging: (limit) => runStagingCleanupPass(readyRuntime, limit), + syncDirectory: (directory, policy) => + runDirectorySyncPass(readyRuntime, directory, policy), close: async () => { if (closeCleanupSchedule !== null) await closeCleanupSchedule(); if (gitHistoryWorker !== null) await gitHistoryWorker.close(); diff --git a/src/identity/keycloak-directory.ts b/src/identity/keycloak-directory.ts new file mode 100644 index 00000000..37c61dde --- /dev/null +++ b/src/identity/keycloak-directory.ts @@ -0,0 +1,249 @@ +import {Effect, Redacted, Schema} from "effect"; + +import { + DirectoryUnavailable, + type DirectoryGroup, + type DirectoryUser, + type MemberDirectory, +} from "../application/directory-sync.js"; +import {loadOidcAuthorizationServer} from "./oidc-oauth-metadata.js"; +import {isLocalOidcIssuer, normalizeOidcEndpoint} from "./oidc-issuer.js"; + +/** Members requested per admin page; a shorter page is the last one. */ +export const keycloakDirectoryPageSize = 100; +/** Refuse a group so large that reading it all is no longer a bounded pass. */ +const maximumPagesPerGroup = 1_000; +const requestTimeoutMilliseconds = 10_000; +const realmPathPattern = /^(?(?:\/[^/]+)*)\/realms\/(?[^/]+)$/u; + +const tokenResponse = Schema.Struct({access_token: Schema.NonEmptyString}); +const groupList = Schema.Array(Schema.Struct({ + id: Schema.NonEmptyString, + name: Schema.String, +})); +const userList = Schema.Array(Schema.Struct({ + email: Schema.optionalKey(Schema.String), + emailVerified: Schema.optionalKey(Schema.Boolean), + enabled: Schema.optionalKey(Schema.Boolean), + firstName: Schema.optionalKey(Schema.String), + id: Schema.NonEmptyString, + lastName: Schema.optionalKey(Schema.String), + username: Schema.String, +})); +const decodeTokenResponse = Schema.decodeUnknownEffect(tokenResponse); +const decodeGroupList = Schema.decodeUnknownEffect(groupList); +const decodeUserList = Schema.decodeUnknownEffect(userList); + +/** + * Derive the Keycloak admin REST base for one realm issuer, or null when the + * issuer is not `/realms/`. + */ +export function keycloakAdminBaseUrl(issuer: string): string | null { + let url: URL; + try { + url = new URL(issuer); + } catch { + return null; + } + const match = realmPathPattern.exec(url.pathname.replace(/\/+$/u, "")); + const realm = match?.groups?.["realm"]; + if (match === null || realm === undefined || url.search !== "" || url.hash !== "") { + return null; + } + const prefix = match.groups?.["prefix"] ?? ""; + return `${url.origin}${prefix}/admin/realms/${realm}`; +} + +export interface KeycloakDirectoryConfig { + /** Admin REST base, as {@link keycloakAdminBaseUrl} derives it. */ + readonly adminBaseUrl: string; + readonly clientId: string; + readonly clientSecret: Redacted.Redacted; + readonly fetch?: typeof globalThis.fetch; + /** The realm issuer whose discovery document names the token endpoint. */ + readonly issuer: string; +} + +/** + * Read group membership through the Keycloak admin REST API with a + * client-credentials token. The client needs only realm-management + * view-users and query-groups. + */ +export class KeycloakDirectory implements MemberDirectory { + readonly #adminBaseUrl: string; + readonly #clientId: string; + readonly #clientSecret: Redacted.Redacted; + readonly #fetch: typeof globalThis.fetch; + readonly #issuer: string; + + constructor(config: KeycloakDirectoryConfig) { + const allowLocalHttp = isLocalOidcIssuer(config.issuer); + const adminBaseUrl = normalizeOidcEndpoint(config.adminBaseUrl, allowLocalHttp); + if (adminBaseUrl === null) { + throw new Error("The Keycloak admin URL must be an HTTPS URL."); + } + this.#adminBaseUrl = adminBaseUrl.replace(/\/+$/u, ""); + this.#clientId = config.clientId; + this.#clientSecret = config.clientSecret; + const providerFetch = config.fetch ?? globalThis.fetch; + this.#fetch = (input, init) => providerFetch(input, init); + this.#issuer = config.issuer; + } + + readonly listGroups = Effect.fn("KeycloakDirectory.listGroups")( + function*(this: KeycloakDirectory, names: readonly string[]) { + const token = yield* this.#accessToken(); + const groups: DirectoryGroup[] = []; + for (const name of names) { + const id = yield* this.#groupId(token, name); + groups.push({name, users: yield* this.#groupMembers(token, name, id)}); + } + return groups; + }, + ); + + readonly #accessToken = Effect.fn("KeycloakDirectory.accessToken")( + function*(this: KeycloakDirectory) { + const tokenEndpoint = yield* Effect.tryPromise({ + try: async () => { + const server = await loadOidcAuthorizationServer(this.#issuer, { + fetch: this.#fetch, + }); + return server.metadata.token_endpoint; + }, + catch: () => unavailable("The Keycloak discovery document could not be read."), + }); + const response = yield* this.#request(tokenEndpoint, { + body: new URLSearchParams({ + client_id: this.#clientId, + client_secret: Redacted.value(this.#clientSecret), + grant_type: "client_credentials", + }), + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }, "The Keycloak token request"); + const body = yield* decodeTokenResponse(response).pipe( + Effect.mapError(() => + unavailable("The Keycloak token response carried no access token.") + ), + ); + return Redacted.make(body.access_token, {label: "keycloak-directory-token"}); + }, + ); + + readonly #groupId = Effect.fn("KeycloakDirectory.groupId")(function*( + this: KeycloakDirectory, + token: Redacted.Redacted, + name: string, + ) { + const url = new URL(`${this.#adminBaseUrl}/groups`); + url.searchParams.set("search", name); + url.searchParams.set("exact", "true"); + const body = yield* this.#adminGet(token, url, `The Keycloak group ${name}`); + const groups = yield* decodeGroupList(body).pipe( + Effect.mapError(() => + unavailable(`The Keycloak group search for ${name} returned an invalid list.`) + ), + ); + const group = groups.find((candidate) => candidate.name === name); + if (group === undefined) { + return yield* unavailable(`The Keycloak group ${name} does not exist.`); + } + return group.id; + }); + + readonly #groupMembers = Effect.fn("KeycloakDirectory.groupMembers")(function*( + this: KeycloakDirectory, + token: Redacted.Redacted, + name: string, + id: string, + ) { + const users: DirectoryUser[] = []; + for (let page = 0; page < maximumPagesPerGroup; page += 1) { + const url = new URL( + `${this.#adminBaseUrl}/groups/${encodeURIComponent(id)}/members`, + ); + url.searchParams.set("first", String(page * keycloakDirectoryPageSize)); + url.searchParams.set("max", String(keycloakDirectoryPageSize)); + url.searchParams.set("briefRepresentation", "true"); + const body = yield* this.#adminGet( + token, + url, + `The Keycloak members of ${name}`, + ); + const members = yield* decodeUserList(body).pipe( + Effect.mapError(() => + unavailable(`The Keycloak members of ${name} are not a user list.`) + ), + ); + for (const member of members) { + users.push({ + email: member.email ?? null, + emailVerified: member.emailVerified === true, + enabled: member.enabled === true, + firstName: member.firstName ?? null, + id: member.id, + lastName: member.lastName ?? null, + username: member.username, + }); + } + if (members.length < keycloakDirectoryPageSize) return users; + } + return yield* unavailable( + `The Keycloak group ${name} has more members than one sync reads.`, + ); + }); + + #adminGet( + token: Redacted.Redacted, + url: URL, + subject: string, + ): Effect.Effect { + return this.#request(url.toString(), { + headers: { + Accept: "application/json", + Authorization: `Bearer ${Redacted.value(token)}`, + }, + method: "GET", + }, subject); + } + + /** One bounded request whose JSON answer is returned only on HTTP 200. */ + #request( + url: string, + init: RequestInit, + subject: string, + ): Effect.Effect { + return Effect.tryPromise({ + try: async (signal) => { + const response = await this.#fetch(url, { + ...init, + // A followed redirect would replay the client secret or the token. + redirect: "manual", + signal: AbortSignal.any([ + signal, + AbortSignal.timeout(requestTimeoutMilliseconds), + ]), + }); + if (response.status !== 200) { + throw new Error(`${subject} returned HTTP ${response.status}.`); + } + const body: Schema.Json = await response.json(); + return body; + }, + catch: (cause) => + unavailable( + cause instanceof Error && cause.message.startsWith(subject) + ? cause.message + : `${subject} failed.`, + ), + }); + } +} + +function unavailable(message: string): DirectoryUnavailable { + return new DirectoryUnavailable({message}); +} diff --git a/src/identity/oidc-groups-claim.ts b/src/identity/oidc-groups-claim.ts new file mode 100644 index 00000000..1fb5fc72 --- /dev/null +++ b/src/identity/oidc-groups-claim.ts @@ -0,0 +1,32 @@ +import type {JWTPayload} from "jose"; +import {Effect, Schema} from "effect"; + +import {IdentityProviderFailure} from "../core/errors.js"; +import type {GroupRolePolicy} from "../core/group-role-policy.js"; + +/** Claim read when an operator enables group roles without naming one. */ +export const defaultOidcGroupsClaim = "groups"; + +/** Generic OIDC group roles: the claim to read and the groups it must name. */ +export interface OidcGroupRoles extends GroupRolePolicy { + readonly claim: string; +} + +const decodeGroupNames = Schema.decodeUnknownEffect(Schema.Array(Schema.String)); + +/** + * Read one configured groups claim from claims the issuer vouched for. No + * configured claim, or a claim the issuer left out, reads as null; a claim in + * any shape other than a list of strings is a provider failure. + */ +export const readOidcGroupsClaim = Effect.fnUntraced(function*( + claims: JWTPayload, + claim: string | null, +): Effect.fn.Return { + if (claim === null || !Object.hasOwn(claims, claim)) return null; + return yield* decodeGroupNames(claims[claim]).pipe( + Effect.mapError(() => new IdentityProviderFailure({ + message: `The OIDC ${claim} claim is not a list of group names.`, + })), + ); +}); diff --git a/src/identity/oidc-hosted-authentication.ts b/src/identity/oidc-hosted-authentication.ts index cd2d874c..a900c19c 100644 --- a/src/identity/oidc-hosted-authentication.ts +++ b/src/identity/oidc-hosted-authentication.ts @@ -2,7 +2,9 @@ import type {Redacted} from "effect"; import type {ExternalMcpBearerVerifier} from "../application/authentication.js"; import type {InteractiveIdentityProvider} from "../application/interactive-login.js"; +import type {GroupRolePolicy} from "../core/group-role-policy.js"; import type {McpOAuthResourceConfiguration} from "../http/create-http-app.js"; +import type {OidcGroupRoles} from "./oidc-groups-claim.js"; import {createOidcIdentityProvider} from "./oidc-identity-provider.js"; import {requireOidcIssuer} from "./oidc-issuer.js"; import { @@ -16,12 +18,15 @@ export interface OidcHostedAuthenticationConfig { readonly clientId: string; readonly clientSecret: Redacted.Redacted | null; readonly fetch?: typeof globalThis.fetch; + readonly groupRoles?: OidcGroupRoles | null; readonly issuer: string; readonly scopes: string; } export interface OidcHostedAuthentication { readonly externalMcpOAuthVerifier: ExternalMcpBearerVerifier; + /** Issuer groups that admit people and set their role, when configured. */ + readonly groupRolePolicy: GroupRolePolicy | null; readonly interactiveIdentityProvider: InteractiveIdentityProvider; readonly mcpOAuthResource: McpOAuthResourceConfiguration; } @@ -32,12 +37,14 @@ export async function createOidcHostedAuthentication( ): Promise { const issuer = requireOidcIssuer(config.issuer, "ARTIFACT_SERVER_OIDC_ISSUER"); const resource = new URL("/mcp", config.applicationOrigin).toString(); + const groupRoles = config.groupRoles ?? null; const authorizationServer = await loadOidcAuthorizationServer( issuer, config.fetch === undefined ? {} : {fetch: config.fetch}, ); let verifierConfig: OidcMcpBearerVerifierConfig = { audience: resource, + groupsClaim: groupRoles?.claim ?? null, issuer, jwksUri: authorizationServer.jwksUri, userInfoEndpoint: authorizationServer.userInfoEndpoint, @@ -47,10 +54,12 @@ export async function createOidcHostedAuthentication( } return { externalMcpOAuthVerifier: new OidcMcpBearerVerifier(verifierConfig), + groupRolePolicy: groupRoles, interactiveIdentityProvider: createOidcIdentityProvider({ applicationOrigin: config.applicationOrigin, clientId: config.clientId, clientSecret: config.clientSecret, + groupRoles, issuer, scopes: config.scopes, }), diff --git a/src/identity/oidc-identity-provider.ts b/src/identity/oidc-identity-provider.ts index 7b283be2..a4894fc8 100644 --- a/src/identity/oidc-identity-provider.ts +++ b/src/identity/oidc-identity-provider.ts @@ -8,6 +8,7 @@ import type { } from "../application/interactive-login.js"; import {IdentityProviderFailure} from "../core/errors.js"; import type {ExternalIdentity} from "../core/installation-identity.js"; +import {type OidcGroupRoles, readOidcGroupsClaim} from "./oidc-groups-claim.js"; import { isLocalOidcIssuer, normalizeOidcEndpoint, @@ -67,6 +68,8 @@ export interface OidcIdentityProviderConfig { readonly clientId: string; readonly clientSecret: Redacted.Redacted | null; readonly fetch?: typeof globalThis.fetch; + /** ID-token claim that lists the person's groups; null reads none. */ + readonly groupsClaim?: string | null; readonly issuer: string; readonly redirectUri: string; readonly scopes: string; @@ -77,6 +80,7 @@ export interface OidcBrowserLoginSettings { readonly applicationOrigin: string; readonly clientId: string; readonly clientSecret: Redacted.Redacted | null; + readonly groupRoles?: OidcGroupRoles | null; readonly issuer: string; readonly scopes: string; } @@ -87,6 +91,7 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { readonly #clientId: string; readonly #clientSecret: Redacted.Redacted | null; readonly #fetch: typeof globalThis.fetch; + readonly #groupsClaim: string | null; readonly #issuer: string; readonly #redirectUri: string; readonly #scopes: string; @@ -98,6 +103,7 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { this.#clientSecret = config.clientSecret; const providerFetch = config.fetch ?? globalThis.fetch; this.#fetch = (input, init) => providerFetch(input, init); + this.#groupsClaim = config.groupsClaim ?? null; this.#issuer = requireOidcIssuer(config.issuer, "The OIDC issuer"); this.#redirectUri = config.redirectUri; this.#scopes = config.scopes; @@ -171,10 +177,20 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { if (nonce === null || claims.data.nonce !== nonce) { return yield* providerFailure(); } + const groups = yield* readOidcGroupsClaim(payload, this.#groupsClaim).pipe( + Effect.catch((failure) => + reportFailure( + "identity.oidc.groups_claim_invalid", + this.#issuer, + failure.message, + ) + ), + ); const identity: ExternalIdentity = { displayName: displayName(claims.data), email: claims.data.email, emailVerified: claims.data.email_verified !== false, + groups, provider: `oidc:${this.#issuer}`, subject: claims.data.sub, }; @@ -216,6 +232,7 @@ export function createOidcIdentityProvider( return new OidcIdentityProvider({ clientId: settings.clientId, clientSecret: settings.clientSecret, + groupsClaim: settings.groupRoles?.claim ?? null, issuer: settings.issuer, redirectUri: new URL("/auth/callback", settings.applicationOrigin).toString(), scopes: settings.scopes, diff --git a/src/identity/oidc-mcp-bearer-verifier.ts b/src/identity/oidc-mcp-bearer-verifier.ts index 9a1a8e27..0a65c896 100644 --- a/src/identity/oidc-mcp-bearer-verifier.ts +++ b/src/identity/oidc-mcp-bearer-verifier.ts @@ -17,6 +17,7 @@ import { IdentityProviderFailure, } from "../core/errors.js"; import type {ExternalIdentity} from "../core/installation-identity.js"; +import {readOidcGroupsClaim} from "./oidc-groups-claim.js"; import {requireOidcIssuer} from "./oidc-issuer.js"; const defaultAlgorithms = ["RS256", "ES256"]; @@ -61,11 +62,19 @@ const decodeUserInfoClaims = Schema.decodeUnknownEffect(userInfoClaims); type AccessTokenClaims = typeof accessTokenClaims.Type; type ProfileClaims = typeof userInfoClaims.Type; +/** Decoded access-token claims beside the verified payload they came from. */ +interface VerifiedAccessToken { + readonly claims: AccessTokenClaims; + readonly payload: JWTPayload; +} + export interface OidcMcpBearerVerifierConfig { readonly algorithms?: readonly string[]; /** The exact MCP resource URL the issuer must bind into `aud`. */ readonly audience: string; readonly fetch?: typeof globalThis.fetch; + /** Claim that lists the person's groups; null reads none. */ + readonly groupsClaim?: string | null; readonly issuer: string; readonly jwksUri: string; readonly userInfoEndpoint?: string | null; @@ -76,6 +85,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { readonly #algorithms: string[]; readonly #audience: string; readonly #fetch: typeof globalThis.fetch; + readonly #groupsClaim: string | null; readonly #issuer: string; readonly #jwks: ReturnType; readonly #provider: string; @@ -86,6 +96,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { this.#audience = config.audience; const providerFetch = config.fetch ?? globalThis.fetch; this.#fetch = (input, init) => providerFetch(input, init); + this.#groupsClaim = config.groupsClaim ?? null; this.#issuer = requireOidcIssuer(config.issuer, "The OIDC issuer"); // Browser login records the same provider name, so one person keeps one // membership whether they arrive through the interface or through MCP. @@ -107,7 +118,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { readonly verify = Effect.fn("OidcMcpBearerVerifier.verify")( function*(this: OidcMcpBearerVerifier, credential: Redacted.Redacted) { - const claims = yield* this.#verifiedClaims(credential); + const {claims} = yield* this.#verifiedToken(credential); const verified: VerifiedExternalMcpBearer = { clientId: claims.client_id ?? claims.azp ?? null, expiresAt: claims.exp, @@ -128,23 +139,24 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { if (verified.provider !== this.#provider) { return yield* invalidToken("The access token issuer is not supported."); } - const claims = yield* this.#verifiedClaims(credential); + const {claims, payload} = yield* this.#verifiedToken(credential); if (claims.sub !== verified.subject) { return yield* invalidToken( "The access token names a different subject than the verified one.", ); } + const groups = yield* readOidcGroupsClaim(payload, this.#groupsClaim); const email = emailOf(claims); - if (email !== null) return this.#identityOf(claims, email); - return yield* this.#userInfoIdentity(credential, claims.sub); + if (email !== null) return this.#identityOf(claims, email, groups); + return yield* this.#userInfoIdentity(credential, claims.sub, groups); }, ); /** Verify signature, issuer, audience, and expiry, then read the claims. */ - #verifiedClaims( + #verifiedToken( credential: Redacted.Redacted, ): Effect.Effect< - AccessTokenClaims, + VerifiedAccessToken, AuthenticationRequired | IdentityProviderFailure > { return Effect.tryPromise({ @@ -160,7 +172,9 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { catch: (cause) => verificationFailure(cause), }).pipe( Effect.flatMap(({payload, protectedHeader}) => - decodeClaims(payload, protectedHeader) + decodeClaims(payload, protectedHeader).pipe( + Effect.map((claims): VerifiedAccessToken => ({claims, payload})), + ) ), ); } @@ -170,6 +184,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { this: OidcMcpBearerVerifier, credential: Redacted.Redacted, subject: string, + tokenGroups: readonly string[] | null, ) { const endpoint = this.#userInfoEndpoint; if (endpoint === null) { @@ -222,11 +237,18 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { "The OIDC profile for this access token carries no email address.", ); } - return this.#identityOf(profile, email); + // The profile answers for groups when it reports them; a lean token's + // own claim stands otherwise. + const groups = yield* readOidcGroupsClaim(body, this.#groupsClaim); + return this.#identityOf(profile, email, groups ?? tokenGroups); }, ); - #identityOf(claims: ProfileClaims, email: string): ExternalIdentity { + #identityOf( + claims: ProfileClaims, + email: string, + groups: readonly string[] | null, + ): ExternalIdentity { return { displayName: displayName(claims, email), email, @@ -234,6 +256,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { // access-token email may be a mutable profile field, and on first use it // can link an admitted member or claim the bootstrap administrator. emailVerified: claims.email_verified === true, + groups, provider: this.#provider, subject: claims.sub, }; diff --git a/src/identity/workos-identity-provider.ts b/src/identity/workos-identity-provider.ts index bdd1d73b..3954dd52 100644 --- a/src/identity/workos-identity-provider.ts +++ b/src/identity/workos-identity-provider.ts @@ -66,6 +66,7 @@ export class WorkOsIdentityProvider implements InteractiveIdentityProvider { displayName: displayName(user), email: user.email, emailVerified: user.emailVerified, + groups: null, provider: this.name, subject: user.id, }; diff --git a/src/identity/workos-mcp-bearer-verifier.ts b/src/identity/workos-mcp-bearer-verifier.ts index c5b09adc..dae7e02b 100644 --- a/src/identity/workos-mcp-bearer-verifier.ts +++ b/src/identity/workos-mcp-bearer-verifier.ts @@ -212,6 +212,7 @@ function workOsExternalIdentity( .join(" ") || user.email, email: user.email, emailVerified: user.email_verified, + groups: null, provider: workOsProviderName, subject: user.id, }; diff --git a/src/lifecycle/directory-sync.ts b/src/lifecycle/directory-sync.ts new file mode 100644 index 00000000..34286f7b --- /dev/null +++ b/src/lifecycle/directory-sync.ts @@ -0,0 +1,21 @@ +import {Effect} from "effect"; + +import type {ApplicationRuntime} from + "../application/application-runtime.js"; +import { + type DirectorySyncReport, + type MemberDirectory, + syncDirectoryMembers, +} from "../application/directory-sync.js"; +import type {GroupRolePolicy} from "../core/group-role-policy.js"; + +/** Run one complete directory sync pass and reject with a stable message. */ +export function runDirectorySyncPass( + runtime: ApplicationRuntime, + directory: MemberDirectory, + policy: GroupRolePolicy, +): Promise { + return runtime.runPromise(syncDirectoryMembers(directory, policy).pipe( + Effect.mapError((error) => new Error(`${error._tag}: ${error.message}`)), + )); +} diff --git a/src/lifecycle/runtime-configuration.ts b/src/lifecycle/runtime-configuration.ts index 7cb319a4..7b8d69b2 100644 --- a/src/lifecycle/runtime-configuration.ts +++ b/src/lifecycle/runtime-configuration.ts @@ -150,6 +150,10 @@ export interface RuntimeConfigurationSummary { }; readonly installationId: string; readonly interactiveIdentityProvider: "local" | "oidc" | "workos"; + /** Present only when an OIDC directory sync is configured. */ + readonly oidcDirectory?: OidcDirectorySummary; + /** Present only when generic OIDC group roles are configured. */ + readonly oidcGroupRoles?: OidcGroupRolesSummary; readonly objectStorageProvider: "filesystem" | ObjectStorageProviderKind; readonly port: number; readonly readinessWithdrawalMilliseconds: number; @@ -490,10 +494,57 @@ const parseS3ObjectStorage = Effect.fn("parseS3ObjectStorage")( }, ); +/** Generic OIDC group roles as the configuration summary reports them. */ +export interface OidcGroupRolesSummary { + readonly administratorGroups: readonly string[]; + readonly claim: string; + readonly memberGroups: readonly string[]; +} + +/** The credential-free part of one OIDC directory sync configuration. */ +export interface OidcDirectorySummary { + readonly adminBaseUrl: string; + readonly clientId: string; + readonly provider: string; +} + /** Render the safe, stable subset of one parsed runtime configuration. */ export function summarizeRuntimeConfiguration( configuration: CompactRuntimeConfiguration | ExternalStorageRuntimeConfiguration, interactiveIdentityProvider: "local" | "oidc" | "workos" = "local", + oidcGroupRoles: OidcGroupRolesSummary | null = null, + oidcDirectory: OidcDirectorySummary | null = null, +): RuntimeConfigurationSummary { + let summary = runtimeConfigurationSummary( + configuration, + interactiveIdentityProvider, + ); + if (oidcGroupRoles !== null) { + summary = { + ...summary, + oidcGroupRoles: { + administratorGroups: oidcGroupRoles.administratorGroups, + claim: oidcGroupRoles.claim, + memberGroups: oidcGroupRoles.memberGroups, + }, + }; + } + if (oidcDirectory !== null) { + summary = { + ...summary, + oidcDirectory: { + adminBaseUrl: oidcDirectory.adminBaseUrl, + clientId: oidcDirectory.clientId, + provider: oidcDirectory.provider, + }, + }; + } + return summary; +} + +function runtimeConfigurationSummary( + configuration: CompactRuntimeConfiguration | ExternalStorageRuntimeConfiguration, + interactiveIdentityProvider: "local" | "oidc" | "workos", ): RuntimeConfigurationSummary { if (configuration.deploymentMode === "compact") { return { diff --git a/src/local/create-local-application-layer.ts b/src/local/create-local-application-layer.ts index 3e390758..99e046c5 100644 --- a/src/local/create-local-application-layer.ts +++ b/src/local/create-local-application-layer.ts @@ -124,6 +124,7 @@ import type { StagedUploadRepository, StagingStore, } from "../core/ports.js"; +import type {GroupRolePolicy} from "../core/group-role-policy.js"; import type {IdentityRepository} from "../core/identity-ports.js"; import type { ManifestEntry } from "../core/model.js"; import {randomBase64Url} from "../core/random.js"; @@ -149,6 +150,8 @@ export interface ApplicationAdapters { readonly externalApiBearerVerifier: BearerCredentialVerifier | null; readonly externalMcpBearerVerifier: BearerCredentialVerifier | null; readonly externalMcpOAuthVerifier: ExternalMcpBearerVerifier | null; + /** Issuer groups that admit people and set their role; absent keeps admission manual. */ + readonly groupRolePolicy?: GroupRolePolicy | null; /** * Agent-dispatch persistence. Every backend implements the dispatch tables, * so omitting the slot is a compile error rather than a runtime failure. @@ -760,6 +763,7 @@ export function createApplicationLayer( const identityLayer = InstallationAccessService.layer({ bootstrapAdministratorEmail: adapters.bootstrapAdministratorEmail, clock: adapters.clock, + groupRolePolicy: adapters.groupRolePolicy ?? null, ids: { apiKeyId: () => `key_${randomUUID()}`, memberId: () => `member_${randomUUID()}`, @@ -876,6 +880,16 @@ export function createApplicationLayer( revokedAt, ), ), + setMemberRole: (installationId, memberId, role, updatedAt) => + identityEffectWithNotFound( + "setMemberRole", + () => identityRepository.setMemberRole( + installationId, + memberId, + role, + updatedAt, + ), + ), }, secrets: { digest: digestIdentitySecret, @@ -942,6 +956,8 @@ export function createApplicationLayer( })); } const verified = yield* verifier.verify(credential); + // Under an issuer group policy this answers null, so every token's + // groups are evaluated again and a removed group takes effect at once. let principal = yield* installationAccess.authenticateExternalSubject( verified.provider, verified.subject, diff --git a/src/local/create-local-runtime.ts b/src/local/create-local-runtime.ts index 42fe1188..1b490f3e 100644 --- a/src/local/create-local-runtime.ts +++ b/src/local/create-local-runtime.ts @@ -10,6 +10,12 @@ import type { import type { InteractiveIdentityProvider } from "../application/interactive-login.js"; import type { Clock } from "../core/ports.js"; import type {BrowserAccess} from "../core/browser-access.js"; +import type {GroupRolePolicy} from "../core/group-role-policy.js"; +import type { + DirectorySyncReport, + MemberDirectory, +} from "../application/directory-sync.js"; +import {runDirectorySyncPass} from "../lifecycle/directory-sync.js"; import { SystemClock, SystemIdGenerator } from "../core/system.js"; import { createHttpApp } from "../http/create-http-app.js"; import type { @@ -94,6 +100,8 @@ export interface LocalRuntimeConfig { readonly externalMcpBearerVerifier?: BearerCredentialVerifier; readonly externalMcpOAuthVerifier?: ExternalMcpBearerVerifier; readonly gitHistory?: NodeGitHistoryConfiguration; + /** Issuer groups that admit people and set their role, when configured. */ + readonly groupRolePolicy?: GroupRolePolicy | null; /** Optional provider seam for real-boundary tests; production composes REST. */ readonly gitHistoryHealthProbe?: GitHistoryProviderHealthProbe; /** Complete provider seam for conformance tests; production composes REST and Git. */ @@ -118,6 +126,11 @@ export interface LocalRuntime { readonly app: ReturnType; cleanupStaging(limit: number): Promise; close(): Promise; + /** Make the members match one directory under the given group roles. */ + syncDirectory( + directory: MemberDirectory, + policy: GroupRolePolicy, + ): Promise; } export async function createLocalRuntime( @@ -223,6 +236,7 @@ export async function createLocalRuntime( externalApiBearerVerifier: config.externalApiBearerVerifier ?? null, externalMcpBearerVerifier: config.externalMcpBearerVerifier ?? null, externalMcpOAuthVerifier: config.externalMcpOAuthVerifier ?? null, + groupRolePolicy: config.groupRolePolicy ?? null, ids: new SystemIdGenerator(), identityRepository, installationId, @@ -331,6 +345,8 @@ export async function createLocalRuntime( return { app, cleanupStaging: (limit) => runStagingCleanupPass(applicationRuntime, limit), + syncDirectory: (directory, policy) => + runDirectorySyncPass(applicationRuntime, directory, policy), close: async () => { if (closeCleanupSchedule !== null) await closeCleanupSchedule(); if (gitHistoryWorker !== null) await gitHistoryWorker.close(); diff --git a/src/storage/postgres-identity-repository.ts b/src/storage/postgres-identity-repository.ts index 281e2d27..2763c382 100644 --- a/src/storage/postgres-identity-repository.ts +++ b/src/storage/postgres-identity-repository.ts @@ -295,6 +295,38 @@ export class PostgresIdentityRepository implements BootstrapManagedApiKeyReposit })); } + async setMemberRole( + installationId: string, + memberId: string, + role: InstallationMember["role"], + updatedAt: string, + ): Promise { + this.#assertInstallationScope(installationId); + const member = await this.#database.run(Effect.gen(function*() { + const sql = yield* SqlClient; + return yield* sql.withTransaction(Effect.gen(function*() { + // Serialize with deactivation, which counts administrators under it. + yield* sql`SELECT pg_advisory_xact_lock( + hashtextextended(${`artifact-server:member-policy:${installationId}`}, 0) + )`; + const rows = yield* sql.unsafe( + `UPDATE installation_members + SET role = $1, status = $2, updated_at = $3 + WHERE installation_id = $4 AND id = $5 + RETURNING id, installation_id AS "installationId", email, + display_name AS "displayName", role, status, + created_at AS "createdAt", updated_at AS "updatedAt"`, + [role, memberStatuses.active, updatedAt, installationId, memberId], + ); + return memberRowSchema.nullable().parse(rows[0] ?? null); + })); + })); + if (member === null) { + throw new IdentityNotFound({message: "The member does not exist."}); + } + return member; + } + async createApplicationSession( command: CreateApplicationSessionRecord, ): Promise { diff --git a/src/storage/sqlite-identity-repository.ts b/src/storage/sqlite-identity-repository.ts index db8ca556..c05a44f8 100644 --- a/src/storage/sqlite-identity-repository.ts +++ b/src/storage/sqlite-identity-repository.ts @@ -339,6 +339,26 @@ export class SqliteIdentityRepository implements BootstrapManagedApiKeyRepositor } } + async setMemberRole( + installationId: string, + memberId: string, + role: InstallationMember["role"], + updatedAt: string, + ): Promise { + const result = this.#database.prepare(` + UPDATE installation_members + SET role = ?, status = ?, updated_at = ? + WHERE installation_id = ? AND id = ? + `).run(role, memberStatuses.active, updatedAt, installationId, memberId); + const member = result.changes === 1 + ? this.#findMember(installationId, memberId) + : null; + if (member === null) { + throw new IdentityNotFound({message: "The member does not exist."}); + } + return member; + } + async createApplicationSession( command: CreateApplicationSessionRecord, ): Promise { diff --git a/tests/cli/directory-sync-cli.test.ts b/tests/cli/directory-sync-cli.test.ts new file mode 100644 index 00000000..f885bf6e --- /dev/null +++ b/tests/cli/directory-sync-cli.test.ts @@ -0,0 +1,185 @@ +import {spawn} from "node:child_process"; +import {existsSync} from "node:fs"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {DatabaseSync} from "node:sqlite"; + +import {afterAll, beforeAll, describe, expect, test} from "vitest"; +import {z} from "zod"; + +import { + keycloakUser, + startStubKeycloak, + type RunningStubKeycloak, +} from "../support/stub-keycloak-directory.js"; + +const administratorEmail = "bootstrap@example.test"; +const repositoryRoot = path.resolve(import.meta.dirname, "../.."); +const memberRowSchema = z.object({ + email: z.string(), + role: z.string(), + status: z.string(), +}); + +interface CommandResult { + readonly exitCode: number; + readonly stderr: string; + readonly stdout: string; +} + +describe("artifactserver maintenance sync-directory", () => { + let compactDirectory: string; + let dataDirectory: string; + let keycloak: RunningStubKeycloak; + + beforeAll(async () => { + compactDirectory = await mkdtemp( + path.join(tmpdir(), "artifact-server-directory-sync-"), + ); + dataDirectory = path.join(compactDirectory, "data"); + const initialized = await runCli([ + "init", + "--admin-email", + administratorEmail, + "--data", + dataDirectory, + ], {}); + if (initialized.exitCode !== 0) { + throw new Error(`Compact initialization failed: ${initialized.stderr}`); + } + keycloak = await startStubKeycloak(); + }); + + afterAll(async () => { + await keycloak.stop(); + await rm(compactDirectory, {force: true, recursive: true}); + }); + + test("refuses to run without group roles and directory settings", async () => { + const plain = await syncDirectory({}); + expect(plain.exitCode).not.toBe(0); + expect(plain.stderr).toContain( + "maintenance sync-directory requires OIDC group roles", + ); + + const withoutDirectory = await syncDirectory({ + ARTIFACT_SERVER_OIDC_CLIENT_ID: "artifact-server", + ARTIFACT_SERVER_OIDC_ISSUER: keycloak.issuer, + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + }); + expect(withoutDirectory.exitCode).not.toBe(0); + expect(withoutDirectory.stderr).toContain( + "maintenance sync-directory requires OIDC group roles", + ); + expect(members()).toEqual([]); + }); + + test("prints one summary line, and fails without writes on a missing group", async () => { + keycloak.groups.set("artifact-admins", [keycloakUser("ada")]); + keycloak.groups.set("artifact-users", [ + keycloakUser("bob"), + keycloakUser("carol", {enabled: false}), + ]); + const synced = await syncDirectory(directoryEnvironment()); + expect(synced.exitCode).toBe(0); + expect(synced.stdout.split("\n")).toEqual([JSON.stringify({ + groups: 2, + directoryUsers: 3, + admitted: 2, + reactivated: 0, + roleChanged: 0, + deactivated: 0, + unchanged: 0, + skipped: 1, + })]); + expect(members()).toEqual([ + {email: "ada@example.test", role: "administrator", status: "active"}, + {email: "bob@example.test", role: "member", status: "active"}, + ]); + expect(synced.stdout + synced.stderr).not.toContain(keycloak.clientSecret); + + keycloak.groups.delete("artifact-users"); + const failed = await syncDirectory(directoryEnvironment()); + expect(failed.exitCode).not.toBe(0); + expect(failed.stderr).toContain( + "The Keycloak group artifact-users does not exist.", + ); + expect(failed.stdout).toBe(""); + expect(members()).toHaveLength(2); + }); + + function directoryEnvironment(): NodeJS.ProcessEnv { + return { + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: "artifact-admins", + ARTIFACT_SERVER_OIDC_CLIENT_ID: "artifact-server", + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID: keycloak.clientId, + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET: keycloak.clientSecret, + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: "keycloak", + ARTIFACT_SERVER_OIDC_ISSUER: keycloak.issuer, + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + }; + } + + function syncDirectory(environment: NodeJS.ProcessEnv): Promise { + return runCli([ + "maintenance", + "sync-directory", + "--once", + "--mode", + "compact", + "--data", + dataDirectory, + ], environment); + } + + function members(): readonly z.infer[] { + const databasePath = path.join(dataDirectory, "artifact-server.db"); + // The database appears with the first runtime; before it, nobody exists. + if (!existsSync(databasePath)) return []; + const database = new DatabaseSync(databasePath, {readOnly: true}); + try { + return database + .prepare("SELECT email, role, status FROM installation_members ORDER BY email") + .all() + .map((row) => memberRowSchema.parse(row)); + } finally { + database.close(); + } + } +}); + +function runCli( + commandArguments: readonly string[], + environment: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + path.join(repositoryRoot, "node_modules/.bin/tsx"), + [path.join(repositoryRoot, "src/cli/main.ts"), ...commandArguments], + { + cwd: repositoryRoot, + env: { + ...process.env, + ARTIFACT_SERVER_CONTENT_DOMAIN: "content.example.net", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.com", + ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE: "0", + ...environment, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const stdout: Uint8Array[] = []; + const stderr: Uint8Array[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ + exitCode: exitCode ?? -1, + stderr: Buffer.concat(stderr).toString("utf8").trim(), + stdout: Buffer.concat(stdout).toString("utf8").trim(), + }); + }); + }); +} diff --git a/tests/cli/oidc-group-roles-configuration.test.ts b/tests/cli/oidc-group-roles-configuration.test.ts new file mode 100644 index 00000000..1019952a --- /dev/null +++ b/tests/cli/oidc-group-roles-configuration.test.ts @@ -0,0 +1,239 @@ +import {spawn} from "node:child_process"; +import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; + +import {Redacted} from "effect"; +import {afterAll, beforeAll, describe, expect, test} from "vitest"; +import {z} from "zod"; + +import {loadOidcConfiguration} from "../../src/cli/oidc-configuration.js"; + +const administratorEmail = "administrator@example.test"; +const repositoryRoot = path.resolve(import.meta.dirname, "../.."); +const completeOidc = { + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: administratorEmail, + ARTIFACT_SERVER_OIDC_CLIENT_ID: "artifact-server", + ARTIFACT_SERVER_OIDC_ISSUER: "https://idp.example.test/realms/main", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.test", +}; +const configurationCheckSchema = z.object({ + configuration: z.looseObject({ + interactiveIdentityProvider: z.string(), + oidcGroupRoles: z.object({ + administratorGroups: z.array(z.string()), + claim: z.string(), + memberGroups: z.array(z.string()), + }).optional(), + }), +}); + +interface CommandResult { + readonly exitCode: number; + readonly output: string; +} + +describe("generic OIDC group-role configuration", () => { + let compactDirectory: string; + + beforeAll(async () => { + compactDirectory = await mkdtemp( + path.join(tmpdir(), "artifact-server-oidc-groups-"), + ); + const initialized = await runCli([ + "init", + "--admin-email", + administratorEmail, + "--data", + path.join(compactDirectory, "data"), + ], {}); + if (initialized.exitCode !== 0) { + throw new Error(`Compact initialization failed: ${initialized.output}`); + } + }); + + afterAll(async () => { + await rm(compactDirectory, {force: true, recursive: true}); + }); + + test("group roles stay off unless a group list names a group", async () => { + const configuration = await loadOidcConfiguration(completeOidc); + expect(configuration?.groupRoles).toBeNull(); + }); + + test("lists are trimmed, deduplicated, case-preserving, and read the groups claim by default", async () => { + const configuration = await loadOidcConfiguration({ + ...completeOidc, + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: " artifact-admins ,Platform Admins,", + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users, artifact-users,Engineering", + }); + expect(configuration?.groupRoles).toEqual({ + administratorGroups: ["artifact-admins", "Platform Admins"], + claim: "groups", + memberGroups: ["artifact-users", "Engineering"], + }); + + const memberOnly = await loadOidcConfiguration({ + ...completeOidc, + ARTIFACT_SERVER_OIDC_GROUPS_CLAIM: " roles ", + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + }); + expect(memberOnly?.groupRoles).toEqual({ + administratorGroups: [], + claim: "roles", + memberGroups: ["artifact-users"], + }); + }); + + test("group settings without an issuer, a claim without lists, and empty lists are refused", async () => { + await expect(loadOidcConfiguration({ + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: administratorEmail, + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.test", + })).rejects.toThrow("Generic OIDC authentication requires"); + await expect(loadOidcConfiguration({ + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: administratorEmail, + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: "artifact-admins", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.test", + })).rejects.toThrow("Generic OIDC authentication requires"); + await expect(loadOidcConfiguration({ + ...completeOidc, + ARTIFACT_SERVER_OIDC_GROUPS_CLAIM: "groups", + })).rejects.toThrow( + "ARTIFACT_SERVER_OIDC_GROUPS_CLAIM requires ARTIFACT_SERVER_OIDC_MEMBER_GROUPS or ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS.", + ); + await expect(loadOidcConfiguration({ + ...completeOidc, + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: " , ,", + })).rejects.toThrow( + "ARTIFACT_SERVER_OIDC_MEMBER_GROUPS must name at least one group.", + ); + }); + + test("directory sync settings parse with a file secret and derive the admin base", async () => { + const secretFile = path.join(compactDirectory, "directory-secret"); + await writeFile(secretFile, "directory-secret-value\n"); + const configuration = await loadOidcConfiguration({ + ...completeOidc, + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID: "artifact-server-directory", + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET_FILE: secretFile, + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: "keycloak", + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + }); + expect(configuration?.directory).toMatchObject({ + adminBaseUrl: "https://idp.example.test/admin/realms/main", + clientId: "artifact-server-directory", + provider: "keycloak", + }); + const secret = configuration?.directory?.clientSecret; + expect(secret === undefined ? null : Redacted.value(secret)) + .toBe("directory-secret-value"); + expect((await loadOidcConfiguration(completeOidc))?.directory).toBeNull(); + }); + + test("directory sync needs group roles, every setting, Keycloak, and a realm issuer", async () => { + const directory = { + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID: "artifact-server-directory", + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET: "directory-secret-value", + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: "keycloak", + }; + await expect(loadOidcConfiguration({...completeOidc, ...directory})) + .rejects.toThrow( + "The ARTIFACT_SERVER_OIDC_DIRECTORY_* settings require ARTIFACT_SERVER_OIDC_MEMBER_GROUPS or ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS.", + ); + const grouped = { + ...completeOidc, + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: "artifact-admins", + }; + await expect(loadOidcConfiguration({ + ...grouped, + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID: "artifact-server-directory", + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: "keycloak", + })).rejects.toThrow("Directory sync requires ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER"); + await expect(loadOidcConfiguration({ + ...grouped, + ...directory, + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: "okta", + })).rejects.toThrow("supports only keycloak"); + await expect(loadOidcConfiguration({ + ...grouped, + ...directory, + ARTIFACT_SERVER_OIDC_ISSUER: "https://idp.example.test/oauth2/default", + })).rejects.toThrow("/realms/"); + await expect(loadOidcConfiguration({ + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: administratorEmail, + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.test", + ...directory, + })).rejects.toThrow("Generic OIDC authentication requires"); + }); + + test("the configuration summary reports group roles only when they are configured", async () => { + const dataDirectory = path.join(compactDirectory, "data"); + const environment = { + ARTIFACT_SERVER_OIDC_CLIENT_ID: "artifact-server", + ARTIFACT_SERVER_OIDC_ISSUER: "https://idp.example.test/realms/main", + }; + const plain = await runCli( + ["config", "check", "--mode", "compact", "--data", dataDirectory], + environment, + ); + const plainSummary = configurationCheckSchema.parse(JSON.parse(plain.output)); + expect(plainSummary.configuration.interactiveIdentityProvider).toBe("oidc"); + expect(plainSummary.configuration).not.toHaveProperty("oidcGroupRoles"); + + const grouped = await runCli( + ["config", "check", "--mode", "compact", "--data", dataDirectory], + { + ...environment, + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: "artifact-admins", + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + }, + ); + expect(configurationCheckSchema.parse(JSON.parse(grouped.output)) + .configuration.oidcGroupRoles).toEqual({ + administratorGroups: ["artifact-admins"], + claim: "groups", + memberGroups: ["artifact-users"], + }); + + const orphaned = await runCli( + ["config", "check", "--mode", "compact", "--data", dataDirectory], + {ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users"}, + ); + expect(orphaned.exitCode).not.toBe(0); + expect(orphaned.output).toContain("Generic OIDC authentication requires"); + }); +}); + +function runCli( + commandArguments: readonly string[], + environment: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + path.join(repositoryRoot, "node_modules/.bin/tsx"), + [path.join(repositoryRoot, "src/cli/main.ts"), ...commandArguments], + { + cwd: repositoryRoot, + env: { + ...process.env, + ARTIFACT_SERVER_CONTENT_DOMAIN: "content.example.net", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.com", + ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE: "0", + ...environment, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const output: Uint8Array[] = []; + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ + exitCode: exitCode ?? -1, + output: Buffer.concat(output).toString("utf8").trim(), + }); + }); + }); +} diff --git a/tests/conformance/installation-identity.test.ts b/tests/conformance/installation-identity.test.ts index f6298d86..a876b885 100644 --- a/tests/conformance/installation-identity.test.ts +++ b/tests/conformance/installation-identity.test.ts @@ -421,6 +421,7 @@ describe("installation identity and access", () => { displayName: "Team administrator", email: "administrator@example.test", emailVerified: true, + groups: null, provider: "test-oidc", subject: "team-administrator", }); @@ -470,6 +471,7 @@ describe("installation identity and access", () => { displayName: "Team administrator", email: "administrator@example.test", emailVerified: true, + groups: null, provider: "test-oidc", subject: "team-administrator", }); @@ -662,6 +664,7 @@ describe("installation identity and access", () => { displayName: "Michael Ramos", email: "ramos@plannotator.ai", emailVerified: true, + groups: null, provider: "test-workos", subject: "workos-user-ramos", }); @@ -835,6 +838,7 @@ describe("installation identity and access", () => { displayName: "Malformed identity", email: "not-an-email-address", emailVerified: true, + groups: null, provider: "test-workos", subject: "workos-user-malformed", }); diff --git a/tests/conformance/oidc-directory-sync.test.ts b/tests/conformance/oidc-directory-sync.test.ts new file mode 100644 index 00000000..25d167ee --- /dev/null +++ b/tests/conformance/oidc-directory-sync.test.ts @@ -0,0 +1,363 @@ +import path from "node:path"; +import {DatabaseSync} from "node:sqlite"; + +import {Redacted} from "effect"; +import {afterEach, beforeEach, describe, expect, test} from "vitest"; +import {z} from "zod"; + +import type {DirectorySyncReport} from + "../../src/application/directory-sync.js"; +import { + browserLoginKinds, + privateTeamBrowserAccess, +} from "../../src/core/browser-access.js"; +import type {OidcGroupRoles} from "../../src/identity/oidc-groups-claim.js"; +import { + KeycloakDirectory, + keycloakAdminBaseUrl, +} from "../../src/identity/keycloak-directory.js"; +import {createOidcIdentityProvider} from + "../../src/identity/oidc-identity-provider.js"; +import {createLocalRuntime} from "../../src/local/create-local-runtime.js"; +import { + createTestInstallation, + removeTestInstallation, + reserveLoopbackPort, + startTestServer, + type TestInstallation, +} from "../support/runtime-harness.js"; +import { + keycloakUser, + startStubKeycloak, + type RunningStubKeycloak, +} from "../support/stub-keycloak-directory.js"; +import { + startStubOidcLogin, + startStubOidcProvider, +} from "../support/stub-oidc-provider.js"; + +const administrators = "artifact-admins"; +const users = "artifact-users"; +const groupRoles: OidcGroupRoles = { + administratorGroups: [administrators], + claim: "groups", + memberGroups: [users], +}; +const memberRowSchema = z.object({ + displayName: z.string(), + email: z.string(), + id: z.string(), + role: z.string(), + status: z.string(), +}); +const bindingRowSchema = z.object({member_id: z.string(), subject: z.string()}); +const sessionSchema = z.object({ + principal: z.object({id: z.string(), membershipRole: z.string()}), +}); + +type MemberRow = z.infer; + +describe("Keycloak directory sync", () => { + let installation: TestInstallation; + let keycloak: RunningStubKeycloak; + + beforeEach(async () => { + installation = await createTestInstallation(); + keycloak = await startStubKeycloak(); + }); + + afterEach(async () => { + await keycloak.stop(); + await removeTestInstallation(installation); + }); + + test("admits every group member with its role across pages, administrators winning", async () => { + const many = Array.from( + {length: 205}, + (_, index) => keycloakUser(`user${String(index).padStart(3, "0")}`), + ); + const ada = keycloakUser("ada", {firstName: " Ada ", lastName: "Lovelace"}); + keycloak.groups.set(users, [...many, ada]); + keycloak.groups.set(administrators, [ada, keycloakUser("bob")]); + + expect(await sync()).toEqual(report({ + admitted: 207, + directoryUsers: 207, + })); + const rows = members(installation); + expect(rows).toHaveLength(207); + expect(byEmail(rows, "ada@example.test")).toMatchObject({ + displayName: "Ada Lovelace", + role: "administrator", + status: "active", + }); + expect(byEmail(rows, "bob@example.test")).toMatchObject({ + displayName: "bob", + role: "administrator", + }); + expect(byEmail(rows, "user204@example.test")).toMatchObject({role: "member"}); + + const memberPages = keycloak.adminRequests() + .filter((request) => request.path.endsWith("/members")) + .map((request) => request.query); + expect(memberPages).toContainEqual({ + briefRepresentation: "true", + first: "200", + max: "100", + }); + expect(memberPages.filter((query) => query.first === "300")).toEqual([]); + expect(keycloak.adminRequests() + .filter((request) => request.path === "/groups") + .map((request) => request.query)) + .toEqual([ + {exact: "true", search: administrators}, + {exact: "true", search: users}, + ]); + + // A second pass with nothing changed writes nothing. + expect(await sync()).toEqual(report({directoryUsers: 207, unchanged: 207})); + }); + + test("roles change both ways, leavers are deactivated, and returners restored", async () => { + keycloak.groups.set(administrators, [keycloakUser("ada")]); + keycloak.groups.set(users, [keycloakUser("bob"), keycloakUser("carol")]); + await sync(); + const before = members(installation); + + keycloak.groups.set(administrators, [keycloakUser("bob")]); + keycloak.groups.set(users, [keycloakUser("ada")]); + expect(await sync()).toEqual(report({ + deactivated: 1, + directoryUsers: 2, + roleChanged: 2, + })); + expect(members(installation)).toEqual([ + {...byEmail(before, "ada@example.test"), role: "member"}, + {...byEmail(before, "bob@example.test"), role: "administrator"}, + {...byEmail(before, "carol@example.test"), status: "inactive"}, + ]); + + keycloak.groups.set(users, [keycloakUser("ada"), keycloakUser("carol")]); + expect(await sync()).toEqual(report({ + directoryUsers: 3, + reactivated: 1, + unchanged: 2, + })); + expect(byEmail(members(installation), "carol@example.test")).toEqual({ + ...byEmail(before, "carol@example.test"), + status: "active", + }); + + // The issuer decides who administers: the last administrator leaves too. + keycloak.groups.set(administrators, []); + keycloak.groups.set(users, [keycloakUser("ada")]); + expect(await sync()).toEqual(report({ + deactivated: 2, + directoryUsers: 1, + unchanged: 1, + })); + expect(byEmail(members(installation), "bob@example.test")) + .toMatchObject({role: "member", status: "inactive"}); + }); + + test("disabled, unverified, and email-less users are skipped and lose membership", async () => { + keycloak.groups.set(administrators, [keycloakUser("ada")]); + keycloak.groups.set(users, [keycloakUser("bob")]); + await sync(); + + keycloak.groups.set(users, [ + keycloakUser("bob", {enabled: false}), + keycloakUser("carol", {emailVerified: false}), + keycloakUser("dave", {email: ""}), + {emailVerified: true, enabled: true, id: "user-erin", username: "erin"}, + ]); + expect(await sync()).toEqual(report({ + deactivated: 1, + directoryUsers: 5, + skipped: 4, + unchanged: 1, + })); + expect(members(installation).map(({email, status}) => ({email, status}))) + .toEqual([ + {email: "ada@example.test", status: "active"}, + {email: "bob@example.test", status: "inactive"}, + ]); + }); + + test("an unknown group, a token failure, a failed page, or an empty directory changes nothing", async () => { + const many = Array.from({length: 150}, (_, index) => keycloakUser(`user${index}`)); + keycloak.groups.set(administrators, [keycloakUser("ada")]); + keycloak.groups.set(users, many); + await sync(); + const baseline = members(installation); + expect(baseline).toHaveLength(151); + + keycloak.groups.delete(administrators); + await expect(sync()).rejects.toThrow( + `DirectoryUnavailable: The Keycloak group ${administrators} does not exist.`, + ); + expect(members(installation)).toEqual(baseline); + keycloak.groups.set(administrators, [keycloakUser("ada")]); + + keycloak.setTokenStatus(503); + await expect(sync()).rejects.toThrow("The Keycloak token request returned HTTP 503."); + expect(members(installation)).toEqual(baseline); + keycloak.setTokenStatus(200); + + // Losing the second page must not read as fifty people leaving. + keycloak.failMembersPage(100); + await expect(sync()).rejects.toThrow(`The Keycloak members of ${users} returned HTTP 500.`); + expect(members(installation)).toEqual(baseline); + keycloak.failMembersPage(null); + + keycloak.groups.set(administrators, [keycloakUser("ada", {enabled: false})]); + keycloak.groups.set(users, Array.from( + {length: 150}, + (_, index) => keycloakUser(`user${index}`, {emailVerified: false}), + )); + await expect(sync()).rejects.toThrow("nothing was changed"); + expect(members(installation)).toEqual(baseline); + + await expect(sync("wrong-secret")).rejects.toThrow("HTTP 401"); + expect(members(installation)).toEqual(baseline); + }); + + test("a member the sync admitted binds to the OIDC subject at first login and keeps the role", async () => { + keycloak.groups.set(administrators, [keycloakUser("ada")]); + keycloak.groups.set(users, [keycloakUser("bob")]); + await sync(); + const [ada] = members(installation); + + const provider = await startStubOidcProvider({clientId: "artifact-server"}); + const port = await reserveLoopbackPort(); + const applicationOrigin = `http://127.0.0.1:${port}`; + const server = await startTestServer(installation, { + applicationOrigin, + bootstrapAdministratorEmail: "bootstrap@example.test", + browserAccess: privateTeamBrowserAccess(browserLoginKinds.oidc), + groupRolePolicy: groupRoles, + interactiveIdentityProvider: createOidcIdentityProvider({ + applicationOrigin, + clientId: provider.clientId, + clientSecret: null, + groupRoles, + issuer: provider.issuer, + scopes: "openid email profile", + }), + port, + }); + try { + provider.claims.email = "ada@example.test"; + provider.claims.subject = "keycloak-subject-ada"; + provider.claims.groups = [administrators]; + const authorization = await startStubOidcLogin(server.baseUrl); + const completed = await fetch(authorization.callbackUrl, { + headers: {Cookie: authorization.handshakeCookie}, + redirect: "manual", + }); + expect(completed.status).toBe(303); + const cookie = completed.headers.getSetCookie() + .map((value) => value.split(";", 1)[0]) + .filter((pair) => + pair?.startsWith("artifact_session=") === true || + pair?.startsWith("artifact_csrf=") === true + ) + .join("; "); + const session = await fetch(`${server.baseUrl}/api/v1/session`, { + headers: {Cookie: cookie}, + }); + expect(sessionSchema.parse(await session.json()).principal).toEqual({ + id: ada?.id, + membershipRole: "administrator", + }); + } finally { + await server.stop(); + await provider.stop(); + } + expect(members(installation)).toHaveLength(2); + expect(bindings(installation)).toEqual([ + {member_id: ada?.id, subject: "keycloak-subject-ada"}, + ]); + }); + + test("the admin base follows a realm issuer and refuses anything else", () => { + expect(keycloakAdminBaseUrl("https://idp.example.test/realms/main")) + .toBe("https://idp.example.test/admin/realms/main"); + expect(keycloakAdminBaseUrl("https://idp.example.test/auth/realms/main")) + .toBe("https://idp.example.test/auth/admin/realms/main"); + expect(keycloakAdminBaseUrl("https://idp.example.test")).toBeNull(); + expect(keycloakAdminBaseUrl("https://idp.example.test/realms")).toBeNull(); + expect(keycloakAdminBaseUrl("https://idp.example.test/realms/main/extra")) + .toBeNull(); + }); + + async function sync(secret = keycloak.clientSecret): Promise { + const runtime = await createLocalRuntime({ + apiToken: installation.apiToken, + browserAccess: privateTeamBrowserAccess(browserLoginKinds.oidc), + contentDomain: "localhost", + dataDirectory: installation.dataDirectory, + groupRolePolicy: groupRoles, + }); + try { + const adminBaseUrl = keycloakAdminBaseUrl(keycloak.issuer); + if (adminBaseUrl === null) throw new Error("The stub issuer is not a realm."); + return await runtime.syncDirectory(new KeycloakDirectory({ + adminBaseUrl, + clientId: keycloak.clientId, + clientSecret: Redacted.make(secret), + issuer: keycloak.issuer, + }), groupRoles); + } finally { + await runtime.close(); + } + } +}); + +function report(changes: Partial): DirectorySyncReport { + return { + groups: 2, + directoryUsers: 0, + admitted: 0, + reactivated: 0, + roleChanged: 0, + deactivated: 0, + unchanged: 0, + skipped: 0, + ...changes, + }; +} + +function byEmail(rows: readonly MemberRow[], email: string): MemberRow { + const row = rows.find((candidate) => candidate.email === email); + if (row === undefined) throw new Error(`No member has ${email}.`); + return row; +} + +function members(installation: TestInstallation): readonly MemberRow[] { + return rowsOf( + installation, + "SELECT id, email, display_name AS displayName, role, status FROM installation_members ORDER BY email", + ).map((row) => memberRowSchema.parse(row)); +} + +function bindings( + installation: TestInstallation, +): readonly z.infer[] { + return rowsOf( + installation, + "SELECT member_id, subject FROM external_identities ORDER BY subject", + ).map((row) => bindingRowSchema.parse(row)); +} + +function rowsOf(installation: TestInstallation, sql: string) { + const database = new DatabaseSync( + path.join(installation.dataDirectory, "artifact-server.db"), + {readOnly: true}, + ); + try { + return database.prepare(sql).all(); + } finally { + database.close(); + } +} diff --git a/tests/conformance/oidc-group-roles.test.ts b/tests/conformance/oidc-group-roles.test.ts new file mode 100644 index 00000000..558c1eac --- /dev/null +++ b/tests/conformance/oidc-group-roles.test.ts @@ -0,0 +1,195 @@ +import path from "node:path"; +import {DatabaseSync} from "node:sqlite"; + +import {afterEach, beforeEach, describe, expect, test} from "vitest"; +import {z} from "zod"; + +import {createOidcIdentityProvider} from + "../../src/identity/oidc-identity-provider.js"; +import type {OidcGroupRoles} from "../../src/identity/oidc-groups-claim.js"; +import { + browserLoginKinds, + privateTeamBrowserAccess, +} from "../../src/core/browser-access.js"; +import { + createTestInstallation, + removeTestInstallation, + reserveLoopbackPort, + startTestServer, + type RunningTestServer, + type TestInstallation, +} from "../support/runtime-harness.js"; +import { + startStubOidcLogin, + startStubOidcProvider, + type RunningStubOidcProvider, +} from "../support/stub-oidc-provider.js"; + +const bootstrapEmail = "administrator@example.test"; +const groupRoles: OidcGroupRoles = { + administratorGroups: ["artifact-admins"], + claim: "roles", + memberGroups: ["artifact-users"], +}; +const sessionResponseSchema = z.object({ + principal: z.object({ + id: z.string(), + membershipRole: z.enum(["administrator", "member"]), + }), +}); +const failureSchema = z.object({ + error: z.object({code: z.string(), message: z.string()}), +}); +const memberRowSchema = z.object({ + email: z.string(), + role: z.string(), + status: z.string(), +}); + +describe("generic OIDC browser login with issuer group roles", () => { + let installation: TestInstallation; + let provider: RunningStubOidcProvider; + let server: RunningTestServer; + + beforeEach(async () => { + installation = await createTestInstallation(); + provider = await startStubOidcProvider({ + clientId: "artifact-server-oidc-groups", + }); + provider.claims.groupsClaim = groupRoles.claim; + const port = await reserveLoopbackPort(); + const applicationOrigin = `http://127.0.0.1:${port}`; + server = await startTestServer(installation, { + applicationOrigin, + bootstrapAdministratorEmail: bootstrapEmail, + browserAccess: privateTeamBrowserAccess(browserLoginKinds.oidc), + groupRolePolicy: groupRoles, + interactiveIdentityProvider: createOidcIdentityProvider({ + applicationOrigin, + clientId: provider.clientId, + clientSecret: null, + groupRoles, + issuer: provider.issuer, + scopes: "openid email profile", + }), + port, + }); + }); + + afterEach(async () => { + await server.stop(); + await provider.stop(); + await removeTestInstallation(installation); + }); + + test("the configured claim admits people by group and every login re-applies their role", async () => { + provider.claims.email = "ada@example.test"; + provider.claims.subject = "subject-ada"; + provider.claims.groups = ["artifact-admins", "artifact-users"]; + const adaFirst = await signIn(); + expect(await sessionRole(adaFirst)).toBe("administrator"); + + provider.claims.email = "grace@example.test"; + provider.claims.subject = "subject-grace"; + provider.claims.groups = ["artifact-users", "sales"]; + expect(await sessionRole(await signIn())).toBe("member"); + + // Ada leaves the administrator group; her next login demotes her, and the + // session she already holds follows the member it belongs to. + provider.claims.email = "ada@example.test"; + provider.claims.subject = "subject-ada"; + provider.claims.groups = ["artifact-users"]; + expect(await sessionRole(await signIn())).toBe("member"); + expect(await sessionRole(adaFirst)).toBe("member"); + + expect(members(installation)).toEqual([ + {email: "ada@example.test", role: "member", status: "active"}, + {email: "grace@example.test", role: "member", status: "active"}, + ]); + }); + + test("a person outside the groups is refused, deactivated, and signed out", async () => { + provider.claims.email = bootstrapEmail; + provider.claims.groups = ["artifact-users"]; + const session = await signIn(); + expect(await sessionRole(session)).toBe("member"); + + provider.claims.groups = ["sales"]; + const refused = await completeLogin(); + expect(refused.status).toBe(403); + expect(refused.headers.getSetCookie()).toEqual([]); + expect(failureSchema.parse(await refused.json()).error).toEqual({ + code: "IDENTITY_ADMISSION_DENIED", + message: "This person is not in a group admitted to the Artifact Server.", + }); + expect(members(installation)).toEqual([ + {email: bootstrapEmail, role: "member", status: "inactive"}, + ]); + const revoked = await fetch(`${server.baseUrl}/api/v1/session`, { + headers: {Cookie: session}, + }); + expect(revoked.status).toBe(401); + + // Without the claim at all, the bootstrap email admits no one either. + provider.claims.groups = null; + expect((await completeLogin()).status).toBe(403); + }); + + test("a groups claim that is not a list of strings fails as a provider failure", async () => { + provider.claims.email = bootstrapEmail; + provider.claims.groups = "artifact-admins"; + const refused = await completeLogin(); + expect(refused.status).toBe(502); + expect(refused.headers.getSetCookie()).toEqual([]); + expect(failureSchema.parse(await refused.json()).error.code) + .toBe("IDENTITY_PROVIDER_FAILURE"); + expect(members(installation)).toEqual([]); + }); + + async function completeLogin(): Promise { + const authorization = await startStubOidcLogin(server.baseUrl); + return fetch(authorization.callbackUrl, { + headers: {Cookie: authorization.handshakeCookie}, + redirect: "manual", + }); + } + + /** Complete one login and return the application cookie header it issued. */ + async function signIn(): Promise { + const completed = await completeLogin(); + expect(completed.status).toBe(303); + const pairs = completed.headers.getSetCookie() + .filter((value) => + value.startsWith("artifact_session=") || value.startsWith("artifact_csrf=") + ) + .map((value) => value.split(";", 1)[0]); + expect(pairs).toHaveLength(2); + return pairs.join("; "); + } + + async function sessionRole(cookieHeader: string): Promise { + const session = await fetch(`${server.baseUrl}/api/v1/session`, { + headers: {Cookie: cookieHeader}, + }); + expect(session.status).toBe(200); + return sessionResponseSchema.parse(await session.json()) + .principal.membershipRole; + } +}); + +function members( + installation: TestInstallation, +): readonly z.infer[] { + const database = new DatabaseSync( + path.join(installation.dataDirectory, "artifact-server.db"), + {readOnly: true}, + ); + try { + return database + .prepare("SELECT email, role, status FROM installation_members ORDER BY email") + .all() + .map((row) => memberRowSchema.parse(row)); + } finally { + database.close(); + } +} diff --git a/tests/conformance/oidc-mcp-authorization.test.ts b/tests/conformance/oidc-mcp-authorization.test.ts index fab15723..73073807 100644 --- a/tests/conformance/oidc-mcp-authorization.test.ts +++ b/tests/conformance/oidc-mcp-authorization.test.ts @@ -1,4 +1,6 @@ import {createServer, type Server} from "node:http"; +import path from "node:path"; +import {DatabaseSync} from "node:sqlite"; import { CLIENT_CAPABILITIES_META_KEY, @@ -15,6 +17,7 @@ import { } from "jose"; import {Effect, Predicate, Redacted} from "effect"; import {afterEach, beforeEach, describe, expect, test} from "vitest"; +import {z} from "zod"; import {createOidcHostedAuthentication} from "../../src/identity/oidc-hosted-authentication.js"; @@ -37,6 +40,14 @@ const realmPath = "/realms/artifact"; const resource = "https://staging.artifactserver.test/mcp"; const subject = "9f1d2c3b-0000-4000-8000-artifactserver"; const userEmail = "administrator@example.test"; +const administratorGroup = "artifact-admins"; +const memberGroup = "artifact-users"; +const memberRowSchema = z.object({ + email: z.string(), + id: z.string(), + role: z.string(), + status: z.string(), +}); describe("generic OIDC MCP authorization", () => { let installation: TestInstallation; @@ -337,12 +348,144 @@ describe("generic OIDC MCP authorization", () => { issueToken({idToken: true}))).rejects.toThrow("ID token is not"); }); + test("without group roles a groups claim, even a malformed one, changes nothing", async () => { + expect.hasAssertions(); + expect((await mcpDiscovery(await issueToken({groups: ["unrelated"]}))) + .status).toBe(200); + expect((await mcpDiscovery(await issueToken({groups: "not-a-list"}))) + .status).toBe(200); + expect(members(installation)).toEqual([expect.objectContaining({ + email: userEmail, + role: "administrator", + status: "active", + })]); + }); + + describe("with issuer group roles", () => { + beforeEach(async () => { + // Replace the plain server before anyone has signed in. + await server.stop(); + const authorizationServer = await loadOidcAuthorizationServer(issuer); + server = await startTestServer(installation, { + bootstrapAdministratorEmail: userEmail, + externalMcpOAuthVerifier: new OidcMcpBearerVerifier({ + audience: resource, + groupsClaim: "groups", + issuer, + jwksUri: authorizationServer.jwksUri, + userInfoEndpoint: authorizationServer.userInfoEndpoint, + }), + groupRolePolicy: { + administratorGroups: [administratorGroup], + memberGroups: [memberGroup], + }, + mcpOAuthResource: { + authorizationServerMetadata: authorizationServer.metadata, + resource, + }, + }); + }); + + test("groups admit a member or an administrator and every later token re-applies them", async () => { + expect.hasAssertions(); + expect((await mcpDiscovery(await issueToken({groups: [memberGroup]}))) + .status).toBe(200); + const [admitted] = members(installation); + expect(admitted).toMatchObject({role: "member", status: "active"}); + + // Promotion and demotion follow the groups of each presented token. + expect((await mcpDiscovery(await issueToken({ + groups: [memberGroup, administratorGroup], + }))).status).toBe(200); + expect(members(installation)).toEqual([ + {...admitted, role: "administrator"}, + ]); + expect((await mcpDiscovery(await issueToken({groups: [memberGroup]}))) + .status).toBe(200); + expect(members(installation)).toEqual([{...admitted, role: "member"}]); + }); + + test("a person in no admitted group is refused and their membership deactivated", async () => { + expect.hasAssertions(); + expect((await mcpDiscovery(await issueToken({ + groups: [administratorGroup], + }))).status).toBe(200); + const [admitted] = members(installation); + expect(admitted).toMatchObject({role: "administrator", status: "active"}); + + // Group names compare exactly, so a differently cased group is no group. + expect((await mcpDiscovery(await issueToken({ + groups: ["Artifact-Admins", "unrelated"], + }))).status).toBe(401); + // The last administrator leaves too: the issuer decides who administers. + expect(members(installation)).toEqual([ + {...admitted, role: "member", status: "inactive"}, + ]); + expect((await mcpDiscovery(await issueToken({}))).status).toBe(401); + + // Returning to a group restores the same member rather than a new one. + expect((await mcpDiscovery(await issueToken({groups: [memberGroup]}))) + .status).toBe(200); + expect(members(installation)).toEqual([ + {...admitted, role: "member", status: "active"}, + ]); + }); + + test("the bootstrap email admits no one outside the groups", async () => { + expect.hasAssertions(); + // Without a policy this verified bootstrap email would become the first + // administrator of the empty installation. + expect((await mcpDiscovery(await issueToken({}))).status).toBe(401); + expect((await mcpDiscovery(await issueToken({groups: []}))).status) + .toBe(401); + expect(members(installation)).toEqual([]); + // Verified email is still required before any group counts. + expect((await mcpDiscovery(await issueToken({ + emailVerified: false, + groups: [administratorGroup], + }))).status).toBe(401); + expect(members(installation)).toEqual([]); + }); + + test("a malformed groups claim is refused as a provider failure", async () => { + expect.hasAssertions(); + expect((await mcpDiscovery(await issueToken({groups: administratorGroup}))) + .status).toBe(500); + expect((await mcpDiscovery(await issueToken({ + groups: [administratorGroup, 7], + }))).status).toBe(500); + expect(members(installation)).toEqual([]); + }); + + test("a lean token takes its groups from userinfo on every call", async () => { + expect.hasAssertions(); + provider.setUserInfoGroups([administratorGroup]); + expect((await mcpDiscovery(await issueToken({profile: false}))).status) + .toBe(200); + expect(members(installation)).toEqual([ + expect.objectContaining({role: "administrator", status: "active"}), + ]); + provider.setUserInfoGroups(null); + // With userinfo silent, the token's own claim decides. + expect((await mcpDiscovery(await issueToken({ + groups: [memberGroup], + profile: false, + }))).status).toBe(200); + expect(members(installation)).toEqual([ + expect.objectContaining({role: "member", status: "active"}), + ]); + expect(provider.userInfoRequests()).toBe(2); + }); + }); + async function issueToken(options: { readonly audience?: string | string[]; /** `null` leaves the claim out. */ readonly emailVerified?: boolean | null; readonly expiresAt?: number; readonly extraClaims?: Readonly>; + /** The `groups` claim, malformed shapes included; left out unless given. */ + readonly groups?: readonly (number | string)[] | string; readonly idToken?: boolean; readonly issuer?: string; readonly profile?: boolean; @@ -368,6 +511,7 @@ describe("generic OIDC MCP authorization", () => { if (options.extraClaims !== undefined) { Object.assign(claims, options.extraClaims); } + if (options.groups !== undefined) claims["groups"] = options.groups; if (options.idToken === true) claims["typ"] = "ID"; const token = new SignJWT(claims) .setProtectedHeader(options.type === undefined @@ -430,10 +574,28 @@ function verifiedSubject( ); } +function members( + installation: TestInstallation, +): readonly z.infer[] { + const database = new DatabaseSync( + path.join(installation.dataDirectory, "artifact-server.db"), + {readOnly: true}, + ); + try { + return database + .prepare("SELECT id, email, role, status FROM installation_members ORDER BY created_at") + .all() + .map((row) => memberRowSchema.parse(row)); + } finally { + database.close(); + } +} + interface ProviderBoundary { readonly issuer: string; close(): Promise; setKeysStatus(status: number): void; + setUserInfoGroups(groups: readonly string[] | null): void; setUserInfoStatus(status: number): void; userInfoRequests(): number; } @@ -442,6 +604,7 @@ async function startProviderBoundary(options: { readonly jwk: JWK; }): Promise { let keysStatus = 200; + let userInfoGroups: readonly string[] | null = null; let userInfoRequestCount = 0; let userInfoStatus = 200; let issuer = ""; @@ -474,13 +637,15 @@ async function startProviderBoundary(options: { return; } response.setHeader("Content-Type", "application/json"); - response.end(JSON.stringify({ + const profile: JWTPayload = { email: userEmail, email_verified: true, family_name: "Administrator", given_name: "Artifact", sub: subject, - })); + }; + if (userInfoGroups !== null) profile["groups"] = userInfoGroups; + response.end(JSON.stringify(profile)); return; } response.statusCode = 404; @@ -498,6 +663,9 @@ async function startProviderBoundary(options: { setKeysStatus: (status) => { keysStatus = status; }, + setUserInfoGroups: (groups) => { + userInfoGroups = groups; + }, setUserInfoStatus: (status) => { userInfoStatus = status; }, diff --git a/tests/core/group-role-policy.test.ts b/tests/core/group-role-policy.test.ts new file mode 100644 index 00000000..21fcf110 --- /dev/null +++ b/tests/core/group-role-policy.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, test} from "vitest"; + +import { + type GroupRolePolicy, + groupMembershipRole, +} from "../../src/core/group-role-policy.js"; + +const policy: GroupRolePolicy = { + administratorGroups: ["artifact-admins"], + memberGroups: ["artifact-users", "engineering"], +}; + +describe("issuer group roles", () => { + test("an administrator group outranks a member group", () => { + expect(groupMembershipRole(policy, ["artifact-admins"])) + .toBe("administrator"); + expect(groupMembershipRole(policy, ["engineering", "artifact-admins"])) + .toBe("administrator"); + }); + + test("any member group admits a member", () => { + expect(groupMembershipRole(policy, ["artifact-users"])).toBe("member"); + expect(groupMembershipRole(policy, ["sales", "engineering"])).toBe("member"); + }); + + test("no admitted group, no reported groups, or a near miss admits no one", () => { + expect(groupMembershipRole(policy, [])).toBeNull(); + expect(groupMembershipRole(policy, null)).toBeNull(); + expect(groupMembershipRole(policy, ["sales"])).toBeNull(); + // Names compare exactly: case, paths, and padding are different groups. + expect(groupMembershipRole(policy, [ + "Artifact-Admins", + "/artifact-admins", + " artifact-users", + ])).toBeNull(); + }); + + test("a policy with one empty list grants only the other role", () => { + expect(groupMembershipRole( + {administratorGroups: [], memberGroups: ["artifact-users"]}, + ["artifact-users"], + )).toBe("member"); + expect(groupMembershipRole( + {administratorGroups: ["artifact-admins"], memberGroups: []}, + ["artifact-users"], + )).toBeNull(); + }); +}); diff --git a/tests/integration/external-storage-runtime.test.ts b/tests/integration/external-storage-runtime.test.ts index e6f3fd25..7e91a754 100644 --- a/tests/integration/external-storage-runtime.test.ts +++ b/tests/integration/external-storage-runtime.test.ts @@ -51,6 +51,10 @@ import { startStubOidcProvider, type RunningStubOidcProvider, } from "../support/stub-oidc-provider.js"; +import { + keycloakUser, + startStubKeycloak, +} from "../support/stub-keycloak-directory.js"; const repositoryRoot = path.resolve(import.meta.dirname, "../.."); const externalStorageCli = path.join(repositoryRoot, "dist/cli/main.js"); @@ -1027,6 +1031,149 @@ describe.sequential("external-storage Postgres and S3 runtime", () => { } }); + test("external-storage foundation: Postgres sets member roles and the directory sync CLI reconciles members", async () => { + const database = await PostgresDatabase.open({ + maxConnections: 2, + url: Redacted.make(environment.databaseUrl), + }, "validate"); + const keycloak = await startStubKeycloak(); + const roleInstallation = `member-role-${randomUUID()}`; + const syncInstallation = `directory-sync-${randomUUID()}`; + try { + await PostgresArtifactRepository.open(database, roleInstallation); + const roles = new PostgresIdentityRepository(database, roleInstallation); + await roles.admitMember({ + createdAt: "2026-10-12T10:00:00.000Z", + displayName: "Manual Member", + email: "manual@example.test", + id: "manual-member", + installationId: roleInstallation, + role: "member", + }); + await expect(roles.setMemberRole( + roleInstallation, + "manual-member", + "administrator", + "2026-10-12T10:01:00.000Z", + )).resolves.toMatchObject({role: "administrator", status: "active"}); + // The last administrator stays; group policy demotes before it removes. + await expect(roles.deactivateMember( + roleInstallation, + "manual-member", + "2026-10-12T10:02:00.000Z", + )).rejects.toMatchObject({_tag: "IdentityConflict"}); + await roles.setMemberRole( + roleInstallation, + "manual-member", + "member", + "2026-10-12T10:03:00.000Z", + ); + await roles.deactivateMember( + roleInstallation, + "manual-member", + "2026-10-12T10:04:00.000Z", + ); + await expect(roles.setMemberRole( + roleInstallation, + "manual-member", + "administrator", + "2026-10-12T10:05:00.000Z", + )).resolves.toMatchObject({role: "administrator", status: "active"}); + await expect(roles.setMemberRole( + roleInstallation, + "absent-member", + "member", + "2026-10-12T10:06:00.000Z", + )).rejects.toMatchObject({_tag: "IdentityNotFound"}); + + // The sync runs through the compiled CLI against a fresh installation. + const syncEnvironment = { + ARTIFACT_SERVER_API_TOKEN: managedTestKey(syncInstallation), + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: oidcAdministratorEmail, + ARTIFACT_SERVER_CONTENT_DOMAIN: "content.example.net", + ARTIFACT_SERVER_DATABASE_URL: environment.databaseUrl, + ARTIFACT_SERVER_INSTALLATION_ID: syncInstallation, + ARTIFACT_SERVER_OIDC_ADMINISTRATOR_GROUPS: "artifact-admins", + ARTIFACT_SERVER_OIDC_CLIENT_ID: "external-storage-integration", + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_ID: keycloak.clientId, + ARTIFACT_SERVER_OIDC_DIRECTORY_CLIENT_SECRET: keycloak.clientSecret, + ARTIFACT_SERVER_OIDC_DIRECTORY_PROVIDER: "keycloak", + ARTIFACT_SERVER_OIDC_ISSUER: keycloak.issuer, + ARTIFACT_SERVER_OIDC_MEMBER_GROUPS: "artifact-users", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.com", + ARTIFACT_SERVER_S3_ACCESS_KEY_ID: environment.accessKey, + ARTIFACT_SERVER_S3_BUCKET: bucket, + ARTIFACT_SERVER_S3_ENDPOINT: environment.endpoint, + ARTIFACT_SERVER_S3_FORCE_PATH_STYLE: "true", + ARTIFACT_SERVER_S3_REGION: region, + ARTIFACT_SERVER_S3_SECRET_ACCESS_KEY: environment.secretKey, + }; + const sync = async () => { + const result = await runExternalCli([ + "maintenance", + "sync-directory", + "--once", + "--mode", + "external-storage", + ], syncEnvironment); + if (result.exitCode !== 0) { + return {failure: result.output}; + } + return {report: z.record(z.string(), z.number()) + .parse(JSON.parse(result.output.split("\n").at(-1) ?? ""))}; + }; + const synced = new PostgresIdentityRepository(database, syncInstallation); + const members = async () => + (await synced.listMembers(syncInstallation)) + .map(({email, role, status}) => ({email, role, status})) + .toSorted((left, right) => left.email.localeCompare(right.email)); + + keycloak.groups.set("artifact-admins", [keycloakUser("ada")]); + keycloak.groups.set("artifact-users", [ + keycloakUser("bob"), + keycloakUser("carol", {emailVerified: false}), + ]); + expect(await sync()).toEqual({report: { + groups: 2, + directoryUsers: 3, + admitted: 2, + reactivated: 0, + roleChanged: 0, + deactivated: 0, + unchanged: 0, + skipped: 1, + }}); + + keycloak.groups.set("artifact-admins", [keycloakUser("bob")]); + keycloak.groups.set("artifact-users", [keycloakUser("ada")]); + expect(await sync()).toMatchObject({report: {roleChanged: 2}}); + expect(await members()).toEqual([ + {email: "ada@example.test", role: "member", status: "active"}, + {email: "bob@example.test", role: "administrator", status: "active"}, + ]); + + // The last administrator leaves with the group, then returns restored. + keycloak.groups.set("artifact-admins", []); + expect(await sync()).toMatchObject({report: {deactivated: 1}}); + keycloak.groups.set("artifact-admins", [keycloakUser("bob")]); + expect(await sync()).toMatchObject({report: {reactivated: 1}}); + expect(await members()).toEqual([ + {email: "ada@example.test", role: "member", status: "active"}, + {email: "bob@example.test", role: "administrator", status: "active"}, + ]); + + keycloak.groups.delete("artifact-users"); + const refused = await sync(); + expect(refused).toEqual({failure: expect.stringContaining( + "The Keycloak group artifact-users does not exist.", + )}); + expect(await members()).toHaveLength(2); + } finally { + await keycloak.stop(); + await database.close(); + } + }); + test("AUTH-009-B AUTH-009-F: sessions, managed keys, and staged uploads cross process boundaries", async () => { expect.hasAssertions(); const externalStorageIdentity = { diff --git a/tests/support/runtime-harness.ts b/tests/support/runtime-harness.ts index 328c4acc..91b10ba9 100644 --- a/tests/support/runtime-harness.ts +++ b/tests/support/runtime-harness.ts @@ -26,6 +26,7 @@ import type { McpOAuthResourceConfiguration, } from "../../src/http/create-http-app.js"; import type {Clock} from "../../src/core/ports.js"; +import type {GroupRolePolicy} from "../../src/core/group-role-policy.js"; import {localOwnerBrowserAccess} from "../../src/core/browser-access.js"; import type {BrowserAccess} from "../../src/core/browser-access.js"; import {defaultCompletedRequestLogSampleRate} from @@ -96,6 +97,7 @@ export async function startTestServer( readonly gitHistory?: NodeGitHistoryConfiguration; readonly gitHistoryHealthProbe?: GitHistoryProviderHealthProbe; readonly gitHistoryProvider?: GitHistoryProvider; + readonly groupRolePolicy?: GroupRolePolicy; readonly hostname?: string; readonly interactiveIdentityProvider?: InteractiveIdentityProvider; readonly linkedFiles?: "off" | "on"; @@ -172,6 +174,9 @@ export async function startTestServer( if (options.gitHistoryProvider !== undefined) { config = {...config, gitHistoryProvider: options.gitHistoryProvider}; } + if (options.groupRolePolicy !== undefined) { + config = {...config, groupRolePolicy: options.groupRolePolicy}; + } if (options.hostname !== undefined) { config = {...config, hostname: options.hostname}; } diff --git a/tests/support/stub-keycloak-directory.ts b/tests/support/stub-keycloak-directory.ts new file mode 100644 index 00000000..69d61d28 --- /dev/null +++ b/tests/support/stub-keycloak-directory.ts @@ -0,0 +1,220 @@ +import {randomUUID} from "node:crypto"; +import {createServer, type IncomingMessage, type Server} from "node:http"; + +import {z} from "zod"; + +const realmName = "artifact"; +const assignedAddressSchema = z.object({port: z.number().int().positive()}); +const tokenFormSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), + grant_type: z.literal("client_credentials"), +}); + +/** One user as Keycloak's brief representation reports it. */ +export interface StubKeycloakUser { + readonly email?: string; + readonly emailVerified: boolean; + readonly enabled: boolean; + readonly firstName?: string; + readonly id: string; + readonly lastName?: string; + readonly username: string; +} + +/** One admin request the stub answered, for pagination assertions. */ +export interface StubKeycloakAdminRequest { + readonly path: string; + readonly query: Readonly>>; +} + +/** A real HTTP boundary that speaks the Keycloak token and admin REST API. */ +export interface RunningStubKeycloak { + readonly clientId: string; + readonly clientSecret: string; + /** Group name to its complete, ordered membership. */ + readonly groups: Map; + readonly issuer: string; + adminRequests(): readonly StubKeycloakAdminRequest[]; + /** Answer the members page starting at `first` with this status instead. */ + failMembersPage(first: number | null, status?: number): void; + setTokenStatus(status: number): void; + stop(): Promise; + tokenRequests(): number; +} + +/** Start one Keycloak realm boundary on loopback. */ +export async function startStubKeycloak(): Promise { + const clientId = "artifact-server-directory"; + const clientSecret = `directory-secret-${randomUUID()}`; + const groups = new Map(); + const groupIds = new Map(); + const adminRequests: StubKeycloakAdminRequest[] = []; + const issuedTokens = new Set(); + let failingPage: {readonly first: number; readonly status: number} | null = null; + let tokenRequestCount = 0; + let tokenStatus = 200; + let issuer = ""; + const realmPath = `/realms/${realmName}`; + const adminPath = `/admin/realms/${realmName}`; + + const groupId = (name: string): string => { + const existing = groupIds.get(name); + if (existing !== undefined) return existing; + const created = randomUUID(); + groupIds.set(name, created); + return created; + }; + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://stub.invalid"); + const json = (status: number, body: string) => { + response.writeHead(status, {"Content-Type": "application/json"}); + response.end(body); + }; + if (url.pathname === `${realmPath}/.well-known/openid-configuration`) { + json(200, JSON.stringify({ + authorization_endpoint: `${issuer}/protocol/openid-connect/auth`, + code_challenge_methods_supported: ["S256"], + issuer, + jwks_uri: `${issuer}/protocol/openid-connect/certs`, + response_types_supported: ["code"], + token_endpoint: `${issuer}/protocol/openid-connect/token`, + })); + return; + } + if ( + request.method === "POST" && + url.pathname === `${realmPath}/protocol/openid-connect/token` + ) { + const answerToken = async () => { + const body = await readBody(request); + tokenRequestCount += 1; + const form = tokenFormSchema.safeParse( + Object.fromEntries(new URLSearchParams(body)), + ); + if ( + !form.success || form.data.client_id !== clientId || + form.data.client_secret !== clientSecret + ) { + json(401, JSON.stringify({error: "unauthorized_client"})); + return; + } + if (tokenStatus !== 200) { + json(tokenStatus, JSON.stringify({error: "server_error"})); + return; + } + const token = `stub-admin-token-${randomUUID()}`; + issuedTokens.add(token); + json(200, JSON.stringify({access_token: token, token_type: "Bearer"})); + }; + answerToken().catch(() => json(500, JSON.stringify({error: "server_error"}))); + return; + } + if (request.method === "GET" && url.pathname.startsWith(`${adminPath}/`)) { + const authorization = request.headers.authorization ?? ""; + if (!issuedTokens.has(authorization.replace(/^Bearer /u, ""))) { + json(401, JSON.stringify({error: "HTTP 401 Unauthorized"})); + return; + } + const query = { + briefRepresentation: url.searchParams.get("briefRepresentation") ?? undefined, + exact: url.searchParams.get("exact") ?? undefined, + first: url.searchParams.get("first") ?? undefined, + max: url.searchParams.get("max") ?? undefined, + search: url.searchParams.get("search") ?? undefined, + }; + const path = url.pathname.slice(adminPath.length); + adminRequests.push({path, query: withoutUndefined(query)}); + if (path === "/groups") { + // Keycloak's exact search still returns matching groups with their + // subgroups; a near-miss name never comes back. + const search = query.search ?? ""; + json(200, JSON.stringify([...groups.keys()] + .filter((name) => + query.exact === "true" ? name === search : name.includes(search) + ) + .map((name) => ({id: groupId(name), name, path: `/${name}`, subGroups: []})))); + return; + } + const members = /^\/groups\/([^/]+)\/members$/u.exec(path); + const name = [...groupIds.entries()] + .find(([, id]) => id === members?.[1])?.[0]; + const users = name === undefined ? undefined : groups.get(name); + if (users === undefined) { + json(404, JSON.stringify({error: "Could not find group by id"})); + return; + } + const first = Number(query.first ?? "0"); + const max = Number(query.max ?? "100"); + if (failingPage !== null && failingPage.first === first) { + json(failingPage.status, JSON.stringify({error: "server_error"})); + return; + } + json(200, JSON.stringify(users.slice(first, first + max))); + return; + } + response.writeHead(404).end(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + issuer = `http://127.0.0.1:${ + assignedAddressSchema.parse(server.address()).port + }${realmPath}`; + return { + adminRequests: () => adminRequests, + clientId, + clientSecret, + failMembersPage: (first, status = 500) => { + failingPage = first === null ? null : {first, status}; + }, + groups, + issuer, + setTokenStatus: (status) => { + tokenStatus = status; + }, + stop: () => closeServer(server), + tokenRequests: () => tokenRequestCount, + }; +} + +/** A directory user with a verified email that sync admits. */ +export function keycloakUser( + username: string, + overrides: Partial = {}, +): StubKeycloakUser { + return { + email: `${username}@example.test`, + emailVerified: true, + enabled: true, + id: `user-${username}`, + username, + ...overrides, + }; +} + +function withoutUndefined( + query: Readonly>, +): StubKeycloakAdminRequest["query"] { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined), + ); +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => error === undefined ? resolve() : reject(error)); + }); +} diff --git a/tests/support/stub-oidc-provider.ts b/tests/support/stub-oidc-provider.ts index c0594360..eaf51f11 100644 --- a/tests/support/stub-oidc-provider.ts +++ b/tests/support/stub-oidc-provider.ts @@ -67,6 +67,10 @@ export interface StubOidcClaims { expiresInSeconds: number; familyName: string | null; givenName: string | null; + /** Group names, or a malformed bare string; null leaves the claim out. */ + groups: readonly string[] | string | null; + /** The claim that carries `groups`. */ + groupsClaim: string; issuedAtSkewSeconds: number; issuer: string | null; name: string | null; @@ -136,6 +140,8 @@ export async function startStubOidcProvider( expiresInSeconds: 300, familyName: null, givenName: null, + groups: null, + groupsClaim: "groups", issuedAtSkewSeconds: 0, issuer: null, name: "Stub Administrator", @@ -318,6 +324,7 @@ function idTokenPayload( } if (claims.familyName !== null) payload["family_name"] = claims.familyName; if (claims.givenName !== null) payload["given_name"] = claims.givenName; + if (claims.groups !== null) payload[claims.groupsClaim] = claims.groups; if (claims.name !== null) payload["name"] = claims.name; return payload; }