Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions deploy/cloudflare/src/d1-identity-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
};
}

Expand Down
121 changes: 121 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<base>/realms/<realm>`; the admin API is then `<base>/admin/realms/<realm>`.
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 `<release>-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.
Expand Down
9 changes: 9 additions & 0 deletions packaging/compose/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions packaging/compose/compose.external-storage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 7 additions & 0 deletions packaging/compose/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
50 changes: 50 additions & 0 deletions packaging/helm/artifact-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<release>-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.

Expand Down
51 changes: 49 additions & 2 deletions packaging/helm/artifact-server/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 -}}
Expand All @@ -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) -}}
Expand Down Expand Up @@ -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" -}}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions packaging/helm/artifact-server/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Loading