12 KiB
| layout | title | parent |
|---|---|---|
| default | IAM Service Protocol Technical Specification | Tech Specs |
IAM Service Protocol Technical Specification
Overview
The IAM service is a backend processor, reached over the standard request/response pub/sub pattern. It is the authority for users, workspaces, API keys, and login credentials. The API gateway delegates to it for authentication resolution and for all user / workspace / key management.
This document defines the wire protocol: the IamRequest and
IamResponse dataclasses, the operation set, the per-operation
input and output fields, the error taxonomy, and the initial HTTP
forwarding endpoint used while IAM is being integrated into the
gateway.
Architectural context — roles, capabilities, workspace scoping,
enforcement boundary — lives in iam.md and
capabilities.md.
Transport
- Request topic:
request:tg/request/iam-request - Response topic:
response:tg/response/iam-response - Pattern: request/response, correlated by the
idmessage property, the same pattern used byconfig-svcandflow-svc. - Caller: the API gateway only. Under the enforcement-boundary policy (see capabilities spec), the IAM service trusts the bus and performs no per-request authentication or capability check against the caller. The gateway has already evaluated capability membership and workspace scoping before sending the request.
Dataclasses
IamRequest
@dataclass
class IamRequest:
# One of the operation strings below.
operation: str = ""
# Scope of this request. Required on every workspace-scoped
# operation. Omitted (or empty) for system-level ops
# (workspace CRUD, signing-key ops, bootstrap, resolve-api-key,
# login).
workspace: str = ""
# Acting user id, for audit. Set by the gateway to the
# authenticated caller's id on user-initiated operations.
# Empty for internal-origin (bootstrap, reconcilers) and for
# resolve-api-key / login (no actor yet).
actor: str = ""
# --- identity selectors ---
user_id: str = ""
username: str = "" # login; unique within a workspace
key_id: str = "" # revoke-api-key, list-api-keys (own)
api_key: str = "" # resolve-api-key (plaintext)
# --- credentials ---
password: str = "" # login, change-password (current)
new_password: str = "" # change-password
# --- user fields ---
user: UserInput | None = None # create-user, update-user
# --- workspace fields ---
workspace_record: WorkspaceInput | None = None # create-workspace, update-workspace
# --- api key fields ---
key: ApiKeyInput | None = None # create-api-key
IamResponse
@dataclass
class IamResponse:
# Populated on success of operations that return them.
user: UserRecord | None = None # create-user, get-user, update-user
users: list[UserRecord] = field(default_factory=list) # list-users
workspace: WorkspaceRecord | None = None # create-workspace, get-workspace, update-workspace
workspaces: list[WorkspaceRecord] = field(default_factory=list) # list-workspaces
# create-api-key returns the plaintext once. Never populated
# on any other operation.
api_key_plaintext: str = ""
api_key: ApiKeyRecord | None = None # create-api-key
api_keys: list[ApiKeyRecord] = field(default_factory=list) # list-api-keys
# login, rotate-signing-key
jwt: str = ""
jwt_expires: str = "" # ISO-8601 UTC
# get-signing-key-public
signing_key_public: str = "" # PEM
# resolve-api-key returns who this key authenticates as.
resolved_user_id: str = ""
resolved_workspace: str = ""
resolved_roles: list[str] = field(default_factory=list)
# reset-password
temporary_password: str = "" # returned once to the operator
# bootstrap: on first run, the initial admin's one-time API key
# is returned for the operator to capture.
bootstrap_admin_user_id: str = ""
bootstrap_admin_api_key: str = ""
# Present on any failed operation.
error: Error | None = None
Value types
@dataclass
class UserInput:
username: str = ""
name: str = ""
email: str = ""
password: str = "" # only on create-user; never on update-user
roles: list[str] = field(default_factory=list)
enabled: bool = True
must_change_password: bool = False
@dataclass
class UserRecord:
id: str = ""
workspace: str = ""
username: str = ""
name: str = ""
email: str = ""
roles: list[str] = field(default_factory=list)
enabled: bool = True
must_change_password: bool = False
created: str = "" # ISO-8601 UTC
# Password hash is never included in any response.
@dataclass
class WorkspaceInput:
id: str = ""
name: str = ""
enabled: bool = True
@dataclass
class WorkspaceRecord:
id: str = ""
name: str = ""
enabled: bool = True
created: str = "" # ISO-8601 UTC
@dataclass
class ApiKeyInput:
user_id: str = ""
name: str = "" # operator-facing label, e.g. "laptop"
expires: str = "" # optional ISO-8601 UTC; empty = no expiry
@dataclass
class ApiKeyRecord:
id: str = ""
user_id: str = ""
name: str = ""
prefix: str = "" # first 4 chars of plaintext, for identification in lists
expires: str = "" # empty = no expiry
created: str = ""
last_used: str = "" # empty if never used
# key_hash is never included in any response.
Operations
| Operation | Request fields | Response fields | Notes |
|---|---|---|---|
login |
username, password, workspace (optional) |
jwt, jwt_expires |
If workspace omitted, IAM resolves to the user's assigned workspace. |
resolve-api-key |
api_key (plaintext) |
resolved_user_id, resolved_workspace, resolved_roles |
Gateway-internal. Service returns auth-failed for unknown / expired / revoked keys. |
change-password |
user_id, password (current), new_password |
— | Self-service. IAM validates password against stored hash. |
reset-password |
user_id |
temporary_password |
Admin-initiated. IAM generates a random password, sets must_change_password=true on the user, returns the plaintext once. |
create-user |
workspace, user |
user |
Admin-only. user.password is hashed and stored; user.roles must be subset of known roles. |
list-users |
workspace |
users |
|
get-user |
workspace, user_id |
user |
|
update-user |
workspace, user_id, user |
user |
password field on user is rejected; use change-password / reset-password. |
disable-user |
workspace, user_id |
— | Soft-delete; sets enabled=false. Revokes all the user's API keys. |
create-workspace |
workspace_record |
workspace |
System-level. |
list-workspaces |
— | workspaces |
System-level. |
get-workspace |
workspace_record (id only) |
workspace |
System-level. |
update-workspace |
workspace_record |
workspace |
System-level. |
disable-workspace |
workspace_record (id only) |
— | System-level. Sets enabled=false; revokes all workspace API keys; disables all users in the workspace. |
create-api-key |
workspace, key |
api_key_plaintext, api_key |
Plaintext returned once; only hash stored. key.name required. |
list-api-keys |
workspace, user_id |
api_keys |
|
revoke-api-key |
workspace, key_id |
— | Deletes the key record. |
get-signing-key-public |
— | signing_key_public |
Gateway fetches this at startup. |
rotate-signing-key |
— | — | System-level. Introduces a new signing key; old key continues to validate JWTs for a grace period (implementation-defined, minimum 1h). |
bootstrap |
— | bootstrap_admin_user_id, bootstrap_admin_api_key |
If IAM tables are empty, creates the initial default workspace, an admin user, an initial API key, and an initial signing key; returns them once. No-op on subsequent calls (returns empty fields). |
Error taxonomy
All errors are carried in the IamResponse.error field. error.type
is one of the values below; error.message is a human-readable
string that is not surfaced verbatim to external callers (the
gateway maps to auth failure / access denied per the IAM error
policy).
type |
When |
|---|---|
invalid-argument |
Malformed request (missing required field, unknown operation, invalid format). |
not-found |
Named resource does not exist (user_id, key_id, workspace). |
duplicate |
Create operation collides with an existing resource (username, workspace id, key name). |
auth-failed |
login with wrong credentials; resolve-api-key with unknown / expired / revoked key; change-password with wrong current password. Single bucket to deny oracle attacks. |
weak-password |
Password does not meet policy (length, complexity — policy defined at service level). |
disabled |
Target user or workspace has enabled=false. |
operation-not-permitted |
Non-admin attempting system-level operation, or workspace-scoped operation attempting to affect another workspace. |
internal-error |
Unexpected IAM-side failure. Log and surface as 500 at the gateway. |
The gateway is responsible for translating auth-failed and
operation-not-permitted into the obfuscated external error
response ("auth failure" / "access denied"); invalid-argument
becomes a descriptive 400; not-found / duplicate /
weak-password / disabled become descriptive 4xx but never leak
IAM-internal detail.
Credential storage
- Passwords are stored using a slow KDF (bcrypt / argon2id — the
service picks; documented as an implementation detail). The
password_hashcolumn stores the full KDF-encoded string (algorithm, cost, salt, hash). Not a plain SHA-256. - API keys are stored as SHA-256 of the plaintext. API keys
are 128-bit random values (
tg_+ base64url); the entropy makes a slow hash unnecessary. The hash serves as the primary key on theiam_api_keystable, enabling O(1) lookup onresolve-api-key. - JWT signing key is stored as an RSA or Ed25519 private key
(implementation choice) in a dedicated
iam_signing_keystable with akid,created, and optionalretiredtimestamp. At most one active key; up to N retired keys are kept for a grace period to validate previously-issued JWTs.
Passwords, API-key plaintext, and signing-key private material are
never returned in any response other than the explicit one-time
responses above (reset-password, create-api-key, bootstrap).
HTTP forwarding (initial integration)
For the initial gateway integration — before the IAM service is wired into the authentication middleware — the gateway exposes a single forwarding endpoint:
POST /api/v1/iam
- Request body is a JSON encoding of
IamRequest. - Response body is a JSON encoding of
IamResponse. - The gateway's existing authentication (
GATEWAY_SECRETbearer) gates access to this endpoint so the IAM protocol can be exercised end-to-end in tests without touching the live auth path. - This endpoint is not the final shape. Once the middleware is
in place, per-operation REST endpoints replace it (for example
POST /api/v1/auth/login,POST /api/v1/users,DELETE /api/v1/api-keys/{id}), and this generic forwarder is removed.
The endpoint performs only message marshalling: it does not read or rewrite fields in the request, and it applies no capability check. All authorisation for user / workspace / key management lands in the subsequent middleware work.
Non-goals for this spec
- REST endpoint shape for the final gateway surface — covered in Phase 2 of the IAM implementation plan, not here.
- OIDC / SAML external IdP protocol — out of scope for open source.
- Key-signing algorithm choice, password KDF choice, JWT claim layout — implementation details captured in code + ADRs, not locked in the protocol spec.