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