Skip to content
Merged
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
32 changes: 32 additions & 0 deletions alembic/versions/024_scim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""SCIM 2.0 user provisioning.

- api_keys may now have the scope 'scim' (exactly one scope per key): a SCIM token, which
can call only /scim/v2 and nothing else; read keys cannot call /scim/v2.
- users.scim_external_id: the identity provider's own id for the user (externalId).

Revision ID: 024
Revises: 023
Create Date: 2026-10-23
"""
from typing import Sequence, Union

from alembic import op

revision: str = "024"
down_revision: Union[str, None] = "023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("ALTER TABLE api_keys DROP CONSTRAINT api_keys_scopes_check")
op.execute("ALTER TABLE api_keys ADD CONSTRAINT api_keys_scopes_check "
"CHECK (scopes <@ ARRAY['read', 'scim']::text[] AND cardinality(scopes) = 1)")
op.execute("ALTER TABLE users ADD COLUMN scim_external_id text NULL CHECK (length(scim_external_id) <= 200)")


def downgrade() -> None:
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS scim_external_id")
op.execute("DELETE FROM api_keys WHERE 'scim' = ANY(scopes)")
op.execute("ALTER TABLE api_keys DROP CONSTRAINT api_keys_scopes_check")
op.execute("ALTER TABLE api_keys ADD CONSTRAINT api_keys_scopes_check CHECK (scopes <@ ARRAY['read']::text[])")
291 changes: 287 additions & 4 deletions apps/api-gateway/main.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/frontend/src/components/ApiKeysSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function ApiKeysSettings() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const load = useCallback(() => api.listApiKeys().then((r) => setKeys(r.api_keys))
const load = useCallback(() => api.listApiKeys().then((r) => setKeys(r.api_keys.filter((k) => !k.scopes.includes('scim'))))
.catch((err) => setError(errorText(err, t('apiKeys.loadFailed', 'Could not load the API keys.')))), [t]);
useEffect(() => { load(); }, [load]);

Expand Down
81 changes: 81 additions & 0 deletions apps/frontend/src/components/ScimSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, ApiKey, SCIM_BASE_URL } from '../services/api';
import { utcDate } from '../services/time';

function errorText(err: any, fallback: string): string {
return err?.response?.data?.detail || fallback;
}

// SCIM 2.0: the identity provider creates, updates and deactivates accounts (admins).
export default function ScimSettings() {
const { t } = useTranslation();
const [tokens, setTokens] = useState<ApiKey[]>([]);
const [created, setCreated] = useState('');
const [error, setError] = useState('');

const load = useCallback(() => api.listApiKeys().then((r) => setTokens(r.api_keys.filter((k) => k.scopes.includes('scim'))))
.catch((err) => setError(errorText(err, t('scim.loadFailed', 'Could not load the SCIM tokens.')))), [t]);
useEffect(() => { load(); }, [load]);

const create = async () => {
setError('');
try {
const { key } = await api.createApiKey('SCIM provisioning', 365, 'scim');
setCreated(key);
await load();
} catch (err) {
setError(errorText(err, t('scim.createFailed', 'Could not create the token.')));
}
};

const revoke = async (k: ApiKey) => {
setError('');
try {
await api.revokeApiKey(k.id);
await load();
} catch (err) {
setError(errorText(err, t('scim.revokeFailed', 'Could not revoke the token.')));
}
};

const active = tokens.filter((k) => !k.revoked_at);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">{t('scim.title', 'User provisioning (SCIM)')}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{t('scim.hint', 'Your identity provider (Entra, Okta, ...) creates accounts, updates names, emails and roles, and deactivates people who leave. Accounts it creates sign in with single sign-on.')}
</p>
<p className="text-xs text-gray-600 dark:text-gray-300 mb-3">
{t('scim.url', 'Tenant URL:')} <code className="font-mono bg-gray-100 dark:bg-gray-700 px-1 rounded select-all">{SCIM_BASE_URL}</code>
</p>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
{created && (
<div className="border border-amber-300 bg-amber-50 rounded-lg p-3 mb-3 text-xs text-amber-900">
<p className="font-medium">{t('scim.token', 'Secret token')}</p>
<input readOnly value={created} onFocus={(e) => e.currentTarget.select()}
className="w-full mt-2 px-2 py-1 border border-amber-300 rounded bg-white font-mono text-[11px]" />
<p className="mt-2">{t('scim.once', 'Paste it into your identity provider now: it will not be shown again. It expires in a year.')}</p>
</div>
)}
{active.length > 0 && (
<ul className="text-sm divide-y divide-gray-100 dark:divide-gray-700 mb-3">
{active.map((k) => (
<li key={k.id} className="py-2 flex items-center justify-between">
<span>
<span className="font-mono text-xs">{k.prefix}…</span>
<span className="text-xs text-gray-500"> · {k.last_used_at
? t('scim.lastUsed', 'last used {{at}}', { at: utcDate(k.last_used_at).toLocaleString() })
: t('scim.unused', 'not used yet')}</span>
</span>
<button type="button" onClick={() => revoke(k)} className="text-xs text-red-600 hover:underline">{t('scim.revoke', 'Revoke')}</button>
</li>
))}
</ul>
)}
<button type="button" onClick={create} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700">
{active.length ? t('scim.another', 'Create another token') : t('scim.create', 'Create SCIM token')}
</button>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/frontend/src/pages/IntegrationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ApiKeysSettings from '../components/ApiKeysSettings';
import WebhooksSettings from '../components/WebhooksSettings';
import ReportsSettings from '../components/ReportsSettings';
import SsoSettings from '../components/SsoSettings';
import ScimSettings from '../components/ScimSettings';

type Tone = 'ok' | 'warn' | 'bad' | 'off';
const TONES: Record<Tone, string> = {
Expand Down Expand Up @@ -129,6 +130,7 @@ export default function IntegrationsPage() {
</Card>

<SsoSettings />
<ScimSettings />
<ApiKeysSettings />
<WebhooksSettings />
<ReportsSettings />
Expand Down
7 changes: 5 additions & 2 deletions apps/frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';

/** Where the browser goes to start single sign-on (a full-page navigation to the gateway).
* bindingHash is the SHA-256 of a value kept in this tab (see services/ssoBinding.ts). */
/** The SCIM 2.0 base URL to enter at an identity provider. */
export const SCIM_BASE_URL = `${API_BASE_URL}/scim/v2`;

export function ssoStartUrl(email: string, next: string, bindingHash: string): string {
return `${API_BASE_URL}/api/v1/auth/sso/start?${new URLSearchParams({ email, next, binding: bindingHash })}`;
}
Expand Down Expand Up @@ -638,8 +641,8 @@ export const api = {
},

/** The key itself is returned only here, once. */
async createApiKey(name: string, expiresInDays?: number): Promise<{ api_key: ApiKey; key: string }> {
return (await apiClient.post('/api/v1/org/api-keys', { name, expires_in_days: expiresInDays ?? null })).data;
async createApiKey(name: string, expiresInDays?: number, scope: 'read' | 'scim' = 'read'): Promise<{ api_key: ApiKey; key: string }> {
return (await apiClient.post('/api/v1/org/api-keys', { name, expires_in_days: expiresInDays ?? null, scope })).data;
},

async revokeApiKey(id: string): Promise<void> {
Expand Down
58 changes: 58 additions & 0 deletions docs/scim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# User provisioning (SCIM)

Your identity provider (Microsoft Entra ID, Okta, OneLogin, …) can manage SCIRM accounts
with **SCIM 2.0**:
- creating them when someone is assigned the application
- updating names, emails and roles
- deactivating people who leave

Use it together with [single sign-on](single-sign-on.md). Accounts that SCIM creates have
no usable password: people sign in with SSO, or set a password with a reset link where
password sign-in is allowed.

## Setting up

In **Administration → Integrations → User provisioning (SCIM)**:
1. **Create SCIM token.** It is shown once and expires in a year.
2. In your identity provider's provisioning settings, enter:
- **Tenant URL**: `<PUBLIC_API_URL>/scim/v2`, as shown on the card
- **Secret token**: the token
3. Map the user's email to `userName`.

A SCIM token is an API key with the scope `scim`. It can call **only** `/scim/v2` (no
other part of the API), and read keys cannot call `/scim/v2`. It is listed with its last
use, and revoking it stops provisioning at the next request.

## What is supported

| | |
|---|---|
| `GET /Users` | Filters `userName eq "…"` and `externalId eq "…"`; paging with `startIndex` and `count` (up to 200). |
| `GET /Users/{id}` | |
| `POST /Users` | `userName` (the email, unique across SCIRM), `name` or `displayName`, `active`, `externalId`, `roles`. |
| `PUT /Users/{id}` | Replace the attributes above. |
| `PATCH /Users/{id}` | `add`/`replace` on `active`, `userName`, `name.*`, `displayName`, `externalId`, `roles`, the work email. Both forms identity providers use: with a `path`, and without one (a value object). `"True"`/`"False"` strings are accepted. |
| `DELETE /Users/{id}` | Deactivates (the account is kept for the audit trail). |
| `ServiceProviderConfig`, `ResourceTypes`, `Schemas` | |

**Roles** are SCIRM's: `admin`, `analyst`, `viewer`, `auditor` (as `roles: [{"value": "analyst"}]`).
Without roles, a new account is a `viewer`.

**Not supported:** groups (map roles to users instead), bulk operations, sorting, ETags,
`remove` operations, and changing passwords.

## Behaviour

- **Deactivating** (`active: false` or `DELETE`):
- the person can no longer sign in, and their sessions cannot renew, so they end within
60 minutes (the access token's lifetime)
- reactivating restores access
- **The last active administrator** of an organization cannot be deactivated or demoted
through SCIM (`409`, `scimType: mutability`), so the organization is never left without
one.
- **Emails are unique across SCIRM.** Creating or renaming to an email used by any account,
in any organization, returns `409` (`uniqueness`).
- **Errors** use the SCIM error format (`urn:ietf:params:scim:api:messages:2.0:Error`, with
`status`, `scimType` and `detail`). Responses are `application/scim+json`.
- **Audit:** every change is recorded in the [audit log](audit-log.md) under *Access and
team*, with the token as the actor (`api-key:<name> (<prefix>)`).
9 changes: 4 additions & 5 deletions docs/single-sign-on.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ sign-in; SCIRM's two-step sign-in is not asked for after single sign-on.
keep it.
3. Invite your members as usual. **SSO does not create accounts.** Someone without an
active SCIRM account in your organization is refused after signing in at the provider.
Automatic provisioning comes with SCIM. In an organization that requires single sign-on,
To provision them automatically, use [SCIM](scim.md). In an organization that requires single sign-on,
accepting an invitation creates the account but does not sign the person in with the
password; they continue with **Sign in with SSO**.

Expand Down Expand Up @@ -96,8 +96,7 @@ Without it, first sign-ins are refused (`no_verified_email`).

## Not built yet

- Creating accounts at first sign-in (SCIM will provision them).
- Creating accounts at first sign-in: use [SCIM](scim.md) to provision them.
- Domain verification.
- Ending SCIRM sessions when the identity provider disables someone (SCIM will deactivate
them).
- Single logout.
- Single logout. People disabled at the identity provider are deactivated through
[SCIM](scim.md); their sessions then end within 60 minutes.
86 changes: 79 additions & 7 deletions libs/common/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def api_key_hash(key: str) -> str:


_SSO_PUBLIC = ("issuer", "client_id", "enforce", "created_by", "updated_at")
_SCIM_FIELDS = ("id", "email", "name", "roles", "is_active", "scim_external_id", "created_at")
_SSO_MEMBER = ("id", "email", "name", "roles", "is_active", "sso_issuer", "sso_subject")


Expand Down Expand Up @@ -580,6 +581,38 @@ async def create_user(self, org: str, email: str, name: str, hashed_password: st
self.users[user["id"]] = user
return _member(user)

# --- SCIM provisioning -------------------------------------------------
def _scim_view(self, u: Dict[str, Any]) -> Dict[str, Any]:
return {**{k: u.get(k) for k in _SCIM_FIELDS}, "updated_at": u.get("updated_at") or u.get("created_at")}

async def scim_users(self, org: str, user_name: Optional[str] = None, start: int = 1,
count: int = 100) -> Tuple[List[Dict[str, Any]], int]:
found = [u for u in self.users.values() if u["organization_id"] == org
and (user_name is None or u["email"] == user_name.lower())]
found.sort(key=lambda u: (u["created_at"], u["id"]))
return [self._scim_view(u) for u in found[start - 1:start - 1 + count]], len(found)

async def scim_user(self, org: str, user_id: str) -> Optional[Dict[str, Any]]:
u = self.users.get(str(user_id))
return self._scim_view(u) if u and u["organization_id"] == org else None

async def scim_set_profile(self, org: str, user_id: str, email: Optional[str] = None, name: Optional[str] = None,
external_id: Optional[str] = None) -> bool:
"""Name, email and the provider's externalId (roles and active go through update_member)."""
u = self.users.get(str(user_id))
if not u or u["organization_id"] != org:
return False
if email is not None and email.lower() != u["email"]:
if self._user_by_email(email):
raise EmailExists(email)
u["email"] = email.lower()
if name is not None:
u["name"] = name
if external_id is not None:
u["scim_external_id"] = external_id
u["updated_at"] = _now()
return True

async def list_members(self, org: str) -> List[Dict[str, Any]]:
return [_member(u) for u in self.users.values() if u["organization_id"] == org]

Expand Down Expand Up @@ -836,10 +869,10 @@ async def record_erp_result(self, org: str, system: str, kind: str, result: Dict

# --- API keys -----------------------------------------------------------
async def create_api_key(self, org: str, name: str, created_by: Optional[str],
expires_at: Optional[str] = None) -> Tuple[Dict[str, Any], str]:
expires_at: Optional[str] = None, scope: str = "read") -> Tuple[Dict[str, Any], str]:
key, prefix, digest = new_api_key()
row = {"id": str(uuid.uuid4()), "organization_id": org, "name": name, "prefix": prefix, "key_hash": digest,
"scopes": ["read"], "created_by": created_by, "created_at": _now(), "expires_at": expires_at,
"scopes": [scope], "created_by": created_by, "created_at": _now(), "expires_at": expires_at,
"last_used_at": None, "revoked_at": None}
self.api_keys[row["id"]] = row
return {k: row[k] for k in _API_KEY_PUBLIC}, key
Expand Down Expand Up @@ -1160,7 +1193,7 @@ def _jsonable(row: Dict[str, Any]) -> Dict[str, Any]:
AUDIT_CATEGORIES: Dict[str, Tuple[str, ...]] = {
"sign_in": ("auth.%",),
"access": ("mfa.%", "invitation.%", "api_key.%", "% /api/v1/org/%", "% /api/v1/auth/%",
"% /api/v1/invitations/%"),
"% /api/v1/invitations/%", "% /scim/v2/%", "sso.%"),
"integrations": ("integrations.%", "suppliers.import.%", "% /api/v1/integrations/%"),
}
AUDIT_CATEGORY_NAMES = (*AUDIT_CATEGORIES, "changes")
Expand Down Expand Up @@ -1875,6 +1908,45 @@ async def create_user(self, org: str, email: str, name: str, hashed_password: st
raise EmailExists(email)
return self._account(rows[0])

# --- SCIM provisioning -------------------------------------------------
_SCIM_COLUMNS = "id, email, name, roles::text[] AS roles, is_active, scim_external_id, created_at, updated_at"

async def scim_users(self, org: str, user_name: Optional[str] = None, start: int = 1,
count: int = 100) -> Tuple[List[Dict[str, Any]], int]:
where = "WHERE CAST(:u AS text) IS NULL OR lower(email) = lower(:u)"
rows = await self._all(org, (f"SELECT {self._SCIM_COLUMNS} FROM users {where} "
"ORDER BY created_at, id OFFSET :o LIMIT :n"),
{"u": user_name, "o": max(start - 1, 0), "n": count})
total = (await self._all(org, f"SELECT count(*) AS n FROM users {where}", {"u": user_name}))[0]["n"]
return [self._account(r) for r in rows], total

async def scim_user(self, org: str, user_id: str) -> Optional[Dict[str, Any]]:
if not _is_uuid(user_id):
return None
rows = await self._all(org, f"SELECT {self._SCIM_COLUMNS} FROM users WHERE id = CAST(:i AS uuid)",
{"i": user_id})
return self._account(rows[0]) if rows else None

async def scim_set_profile(self, org: str, user_id: str, email: Optional[str] = None, name: Optional[str] = None,
external_id: Optional[str] = None) -> bool:
"""Name, email and the provider's externalId (roles and active go through update_member)."""
from sqlalchemy.exc import IntegrityError
if not _is_uuid(user_id):
return False
if email is not None:
other = await self.find_login_user(email)
if other and str(other["id"]) != str(user_id):
raise EmailExists(email)
try:
rows = await self._all(org, (
"UPDATE users SET email = COALESCE(:e, email), name = COALESCE(:n, name), "
"scim_external_id = COALESCE(:x, scim_external_id), updated_at = now() "
"WHERE id = CAST(:i AS uuid) RETURNING id"),
{"e": email.lower() if email else None, "n": name, "x": external_id, "i": user_id})
except IntegrityError:
raise EmailExists(email or "")
return bool(rows)

async def list_members(self, org: str) -> List[Dict[str, Any]]:
rows = await self._all(org, f"SELECT {self._MEMBER_COLUMNS} FROM users ORDER BY created_at, email")
return [self._account(r) for r in rows]
Expand Down Expand Up @@ -2189,14 +2261,14 @@ async def record_audit(self, org: Optional[str], entry: Dict[str, Any]) -> None:
_API_KEY_COLUMNS = ", ".join(_API_KEY_PUBLIC)

async def create_api_key(self, org: str, name: str, created_by: Optional[str],
expires_at: Optional[str] = None) -> Tuple[Dict[str, Any], str]:
expires_at: Optional[str] = None, scope: str = "read") -> Tuple[Dict[str, Any], str]:
key, prefix, digest = new_api_key()
expires = _naive_utc(expires_at)
rows = await self._all(org, (
"INSERT INTO api_keys (organization_id, name, prefix, key_hash, created_by, expires_at) "
"VALUES (CAST(:org AS uuid), :n, :p, :h, :by, :exp) "
"INSERT INTO api_keys (organization_id, name, prefix, key_hash, scopes, created_by, expires_at) "
"VALUES (CAST(:org AS uuid), :n, :p, :h, CAST(:sc AS text[]), :by, :exp) "
f"RETURNING {self._API_KEY_COLUMNS}"),
{"org": org, "n": name, "p": prefix, "h": digest, "by": created_by,
{"org": org, "n": name, "p": prefix, "h": digest, "sc": [scope], "by": created_by,
"exp": expires.replace(tzinfo=timezone.utc) if expires else None})
return self._api_key_row(rows[0]), key

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ nav:
- Two-step sign-in: two-step-sign-in.md
- Passwords: passwords.md
- Single sign-on: single-sign-on.md
- User provisioning (SCIM): scim.md
- Audit log: audit-log.md
- Public API: public-api.md
- Webhooks: webhooks.md
Expand Down
Loading
Loading