mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-04-25 08:26:21 +02:00
329 lines
14 KiB
Markdown
329 lines
14 KiB
Markdown
---
|
|
layout: default
|
|
title: "IAM Service Protocol Technical Specification"
|
|
parent: "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`](iam.md) and
|
|
[`capabilities.md`](capabilities.md).
|
|
|
|
## Transport
|
|
|
|
- **Request topic:** `request:tg/request/iam-request`
|
|
- **Response topic:** `response:tg/response/iam-response`
|
|
- **Pattern:** request/response, correlated by the `id` message
|
|
property, the same pattern used by `config-svc` and `flow-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`
|
|
|
|
```python
|
|
@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`
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
@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_hash` column 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 the `iam_api_keys` table, enabling O(1) lookup on
|
|
`resolve-api-key`.
|
|
- **JWT signing key** is stored as an RSA or Ed25519 private key
|
|
(implementation choice) in a dedicated `iam_signing_keys` table
|
|
with a `kid`, `created`, and optional `retired` timestamp. 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`).
|
|
|
|
## Bootstrap modes
|
|
|
|
`iam-svc` requires a bootstrap mode to be chosen at startup. There is
|
|
no default — an unset or invalid mode causes the service to refuse
|
|
to start. The purpose is to force the operator to make an explicit
|
|
security decision rather than rely on an implicit "safe" fallback.
|
|
|
|
| Mode | Startup behaviour | `bootstrap` operation | Suitability |
|
|
|---|---|---|---|
|
|
| `token` | On first start with empty tables, auto-seeds the `default` workspace, admin user, admin API key (using the operator-provided `--bootstrap-token`), and an initial signing key. No-op on subsequent starts. | Refused — returns `auth-failed` / `"auth failure"` regardless of caller. | Production, any public-exposure deployment. |
|
|
| `bootstrap` | No startup seeding. Tables remain empty until the `bootstrap` operation is invoked over the pub/sub bus (typically via `tg-bootstrap-iam`). | Live while tables are empty. Generates and returns the admin API key once. Refused (`auth-failed`) once tables are populated. | Dev / compose up / CI. **Not safe under public exposure** — any caller reaching the gateway's `/api/v1/iam` forwarder before the operator can cause a token to be issued to them. Operators choosing this mode accept that risk. |
|
|
|
|
### Error masking
|
|
|
|
In both modes, any refused invocation of the `bootstrap` operation
|
|
returns the same error (`auth-failed` / `"auth failure"`). A caller
|
|
cannot distinguish:
|
|
|
|
- "service is in token mode"
|
|
- "service is in bootstrap mode but already bootstrapped"
|
|
- "operation forbidden"
|
|
|
|
This matches the general IAM error-policy stance (see `iam.md`) and
|
|
prevents externally enumerating IAM's state.
|
|
|
|
### Bootstrap-token lifecycle
|
|
|
|
The bootstrap token — whether operator-supplied (`token` mode) or
|
|
service-generated (`bootstrap` mode) — is a one-time credential. It
|
|
is stored as admin's single API key, tagged `name="bootstrap"`. The
|
|
operator's first admin action after bootstrap should be:
|
|
|
|
1. Create a durable admin user and API key (or issue a durable API
|
|
key to the bootstrap admin).
|
|
2. Revoke the bootstrap key via `revoke-api-key`.
|
|
3. Remove the bootstrap token from any deployment configuration.
|
|
|
|
The `name="bootstrap"` marker makes bootstrap keys easy to detect in
|
|
tooling (e.g. a `tg-list-api-keys` filter).
|
|
|
|
## 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_SECRET` bearer)
|
|
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.
|
|
|
|
## References
|
|
|
|
- [Identity and Access Management Specification](iam.md)
|
|
- [Capability Vocabulary Specification](capabilities.md)
|