mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-14 07:42:11 +02:00
iam: self-service ops, optional workspace filters, Mux service routing
Three threads, all reinforcing the contract's system-level vs.
workspace-association distinction.
WS Mux service routing
- tg-show-flows (and any workspace-level service over the WS) was
failing with "unknown service" because the post-refactor Mux
unconditionally looked up flow-service:<kind>. Now branches on
the envelope's flow field: with flow → flow-service:<kind>;
without flow → <kind>:<op> from the inner body; with bare op
lookup for service=iam. Resource and parameters come from the
matched op's own extractors — same path the HTTP endpoints take.
Optional workspace on system-level user/key ops
- list-users returns the deployment-wide list when no workspace is
supplied, filters when one is. get-user, update-user,
disable-user, enable-user, delete-user, reset-password,
create-api-key, list-api-keys, revoke-api-key all treat workspace
as an optional integrity check rather than a required argument.
- create-user keeps workspace required — there it's the new user's
home-workspace binding, a parameter rather than an address.
- API keys reclassified as SYSTEM-level resources. By the same
reasoning that makes users system-level, an API key is a
credential record on a deployment-wide registry; the workspace it
authenticates to is a property, not a containment.
Self-service surface
- whoami: returns the caller's own user record. AUTHENTICATED-only;
no users:read capability required. Foundation for UI affordances
that depend on the caller's permissions.
- bootstrap-status: POST /api/v1/auth/bootstrap-status, PUBLIC,
side-effect-free. Returns {bootstrap_available: bool} so a
first-run UI can decide whether to render setup without consuming
the bootstrap op.
- Gateway now injects actor=identity.handle on every authenticated
forward to iam-svc (IamEndpoint and WS Mux iam path), overwriting
any caller-supplied value. Underpins whoami, audit logging, and
future regime-side decisions that need actor identity.
- tg-whoami and tg-update-user CLIs.
Spec polish
- iam-contract.md: actor-injection rule documented; whoami /
bootstrap-status added to operations list; permission-scope
framing tightened (workspace scope is a property of the grant,
not the user or role).
- iam.md: self-service section; gateway flow gains the actor-
injection step; role section reframed so iam-svc constraints
don't leak into contract-level prose.
- iam-protocol.md: ops table updated for whoami, bootstrap-status,
optional-workspace pattern; bootstrap_available added to the
IamResponse listing.
This commit is contained in:
parent
6302eb8c97
commit
8eba112f88
15 changed files with 555 additions and 147 deletions
|
|
@ -83,17 +83,16 @@ The four arguments separate concerns:
|
||||||
identifier. See *The Resource model* below.
|
identifier. See *The Resource model* below.
|
||||||
- **`parameters`** — operation-specific data that the regime may
|
- **`parameters`** — operation-specific data that the regime may
|
||||||
need to consider beyond the resource identifier. Used when a
|
need to consider beyond the resource identifier. Used when a
|
||||||
decision depends on attributes the request supplies — e.g. an
|
decision depends on attributes the request supplies — e.g.
|
||||||
admin scoped to one workspace creating a user *with workspace
|
creating a user *with workspace association W*: the resource is
|
||||||
association W*: the resource is the system-level user registry,
|
the system-level user registry, and W is a parameter the regime
|
||||||
and W is a parameter the regime checks against the admin's
|
checks against the caller's permissions for `users:write`.
|
||||||
scope.
|
|
||||||
|
|
||||||
Different regimes use the four arguments differently — the OSS
|
Different regimes use the four arguments differently — one regime
|
||||||
regime checks role bundles against the capability and the role's
|
might evaluate role bundles whose grants carry workspace scope;
|
||||||
workspace scope against parameters; an SSO regime might consult an
|
another might consult upstream IdP group memberships; an ABAC
|
||||||
upstream IdP's group memberships; an ABAC regime evaluates a
|
regime evaluates a policy with all four as inputs. The contract
|
||||||
policy with all four as inputs. The contract is unchanged.
|
is unchanged.
|
||||||
|
|
||||||
### `authorise_many`
|
### `authorise_many`
|
||||||
|
|
||||||
|
|
@ -129,14 +128,49 @@ most of them) but the operation set the gateway can forward is:
|
||||||
`revoke-api-key`, `change-password`, `reset-password`
|
`revoke-api-key`, `change-password`, `reset-password`
|
||||||
- Workspace management: `create-workspace`, `list-workspaces`,
|
- Workspace management: `create-workspace`, `list-workspaces`,
|
||||||
`get-workspace`, `update-workspace`, `disable-workspace`
|
`get-workspace`, `update-workspace`, `disable-workspace`
|
||||||
- Session management: `login`
|
- Session management: `login`, `whoami`
|
||||||
- Key management: `get-signing-key-public`, `rotate-signing-key`
|
- Key management: `get-signing-key-public`, `rotate-signing-key`
|
||||||
- Bootstrap: `bootstrap`
|
- Bootstrap: `bootstrap`, `bootstrap-status`
|
||||||
|
|
||||||
|
`whoami` is the self-read counterpart to `get-user`: any
|
||||||
|
authenticated caller can read their own identity record without
|
||||||
|
holding a user-management capability. It is the gating-free probe
|
||||||
|
a UI uses to render affordances appropriate to the caller's role.
|
||||||
|
|
||||||
|
`bootstrap-status` is a side-effect-free probe of whether an
|
||||||
|
unconsumed `bootstrap` call would currently succeed. It exists so
|
||||||
|
a first-run UI can decide whether to render setup without invoking
|
||||||
|
the consuming `bootstrap` op. Public — no authentication.
|
||||||
|
|
||||||
A regime that does not support one of these (e.g. an SSO regime
|
A regime that does not support one of these (e.g. an SSO regime
|
||||||
where users are managed in the IdP) returns a defined "not
|
where users are managed in the IdP) returns a defined "not
|
||||||
supported" error; the gateway surfaces it as a 501.
|
supported" error; the gateway surfaces it as a 501.
|
||||||
|
|
||||||
|
### Actor injection
|
||||||
|
|
||||||
|
For any management operation forwarded by the gateway after
|
||||||
|
authentication, the gateway injects the authenticated caller's
|
||||||
|
`handle` as an `actor` field on the request. Regimes use `actor`
|
||||||
|
to identify *who is making the request* — distinct from the
|
||||||
|
operation's target (which lives in `user_id` / `key_id` /
|
||||||
|
`workspace_record` / etc.) — for purposes such as:
|
||||||
|
|
||||||
|
- Self-service operations (`whoami`, `change-password`) that
|
||||||
|
resolve "the caller" without taking a target argument.
|
||||||
|
- Audit logging, where the actor is recorded against the change.
|
||||||
|
- Decisions that depend on the resolved resource state. The
|
||||||
|
gateway authorises against the parameters on the request, but it
|
||||||
|
cannot know the resolved resource's actual properties (e.g. the
|
||||||
|
workspace association of a target user) before the regime loads
|
||||||
|
it. When that matters, the regime can re-decide using the
|
||||||
|
actor's permissions and the resolved record — closing a class
|
||||||
|
of cases the gateway-side check can't see.
|
||||||
|
|
||||||
|
Caller-supplied `actor` values on the request body are overwritten
|
||||||
|
by the gateway — the gateway is the only authority for actor
|
||||||
|
identity, and a regime that consults `actor` can rely on it being
|
||||||
|
authentic.
|
||||||
|
|
||||||
## The `Identity` surface
|
## The `Identity` surface
|
||||||
|
|
||||||
`Identity` is *mostly* opaque. The gateway holds the value as a
|
`Identity` is *mostly* opaque. The gateway holds the value as a
|
||||||
|
|
@ -327,13 +361,16 @@ contract via:
|
||||||
- Credentials are API keys (opaque) or JWTs (Ed25519, locally
|
- Credentials are API keys (opaque) or JWTs (Ed25519, locally
|
||||||
validated by the gateway against the regime's published public
|
validated by the gateway against the regime's published public
|
||||||
key).
|
key).
|
||||||
- `authorise` reduces to a role-and-workspace-scope check against
|
- `authorise` reduces to a lookup against the role bundles in
|
||||||
the role table defined in [`capabilities.md`](capabilities.md).
|
[`capabilities.md`](capabilities.md), with each grant's workspace
|
||||||
|
scope checked against the operation's workspace component.
|
||||||
- Identity, user, and workspace records live in Cassandra.
|
- Identity, user, and workspace records live in Cassandra.
|
||||||
|
|
||||||
The OSS regime is deliberately simple — three roles, single
|
The OSS regime is deliberately simple — three roles, a single
|
||||||
home-workspace per user (a regime data-model decision, not a
|
workspace association per user (a regime data-model decision, not
|
||||||
contract assertion), no policy language.
|
a contract assertion), no policy language. Other regimes can
|
||||||
|
grant the same user different permissions in different workspaces
|
||||||
|
without changing anything outside the regime.
|
||||||
|
|
||||||
### Future regimes
|
### Future regimes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,16 @@ class IamRequest:
|
||||||
# login).
|
# login).
|
||||||
workspace: str = ""
|
workspace: str = ""
|
||||||
|
|
||||||
# Acting user id, for audit. Set by the gateway to the
|
# Acting user id. Set by the gateway to the authenticated
|
||||||
# authenticated caller's id on user-initiated operations.
|
# caller's identity handle for every authenticated request
|
||||||
# Empty for internal-origin (bootstrap, reconcilers) and for
|
# (overwrites any caller-supplied value — the gateway is the
|
||||||
# resolve-api-key / login (no actor yet).
|
# only authority for actor identity, so handlers can rely on it
|
||||||
|
# being authentic). Used for audit logging, self-service ops
|
||||||
|
# like ``whoami`` that resolve "the caller", and future actor-
|
||||||
|
# scoped policy checks. Empty for unauthenticated ops
|
||||||
|
# (``login``, ``bootstrap``, ``bootstrap-status``,
|
||||||
|
# ``get-signing-key-public``, ``resolve-api-key``). See the
|
||||||
|
# actor-injection rule in the IAM contract spec.
|
||||||
actor: str = ""
|
actor: str = ""
|
||||||
|
|
||||||
# --- identity selectors ---
|
# --- identity selectors ---
|
||||||
|
|
@ -135,6 +141,11 @@ class IamResponse:
|
||||||
bootstrap_admin_user_id: str = ""
|
bootstrap_admin_user_id: str = ""
|
||||||
bootstrap_admin_api_key: str = ""
|
bootstrap_admin_api_key: str = ""
|
||||||
|
|
||||||
|
# bootstrap-status: true iff an unconsumed ``bootstrap`` call
|
||||||
|
# would currently succeed. Always emitted by the response
|
||||||
|
# translator (the false case is meaningful for first-run UIs).
|
||||||
|
bootstrap_available: bool = False
|
||||||
|
|
||||||
# Present on any failed operation.
|
# Present on any failed operation.
|
||||||
error: Error | None = None
|
error: Error | None = None
|
||||||
```
|
```
|
||||||
|
|
@ -201,25 +212,29 @@ class ApiKeyRecord:
|
||||||
| Operation | Request fields | Response fields | Notes |
|
| 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. |
|
| `login` | `username`, `password`, `workspace` (optional) | `jwt`, `jwt_expires` | If `workspace` omitted, IAM resolves to the user's assigned workspace. |
|
||||||
|
| `whoami` | `actor` (gateway-injected) | `user` | Returns the calling user's own record. AUTHENTICATED-only; no `users:read` capability required. |
|
||||||
| `resolve-api-key` | `api_key` (plaintext) | `resolved_user_id`, `resolved_workspace`, `resolved_roles` | Gateway-internal. Service returns `auth-failed` for unknown / expired / revoked keys. |
|
| `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. |
|
| `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. |
|
| `reset-password` | `user_id`, `workspace` (optional integrity check) | `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. |
|
| `create-user` | `workspace`, `user` | `user` | `user.password` is hashed and stored; `user.roles` must be subset of known roles. `workspace` is the new user's home-workspace binding (a required *parameter*, not an address). |
|
||||||
| `list-users` | `workspace` | `users` | |
|
| `list-users` | `workspace` (optional filter) | `users` | If `workspace` omitted, returns the deployment-wide list. |
|
||||||
| `get-user` | `workspace`, `user_id` | `user` | |
|
| `get-user` | `user_id`, `workspace` (optional integrity check) | `user` | |
|
||||||
| `update-user` | `workspace`, `user_id`, `user` | `user` | `password` field on `user` is rejected; use `change-password` / `reset-password`. |
|
| `update-user` | `user_id`, `user`, `workspace` (optional integrity check) | `user` | `password` field on `user` is rejected; use `change-password` / `reset-password`. Username is immutable. |
|
||||||
| `disable-user` | `workspace`, `user_id` | — | Soft-delete; sets `enabled=false`. Revokes all the user's API keys. |
|
| `disable-user` | `user_id`, `workspace` (optional integrity check) | — | Soft-delete; sets `enabled=false`. Revokes all the user's API keys. |
|
||||||
|
| `enable-user` | `user_id`, `workspace` (optional integrity check) | — | Re-enables a previously disabled user; does not restore API keys. |
|
||||||
|
| `delete-user` | `user_id`, `workspace` (optional integrity check) | — | Hard-delete; removes user record, username lookup, and all the user's API keys. |
|
||||||
| `create-workspace` | `workspace_record` | `workspace` | System-level. |
|
| `create-workspace` | `workspace_record` | `workspace` | System-level. |
|
||||||
| `list-workspaces` | — | `workspaces` | System-level. |
|
| `list-workspaces` | — | `workspaces` | System-level. |
|
||||||
| `get-workspace` | `workspace_record` (id only) | `workspace` | System-level. |
|
| `get-workspace` | `workspace_record` (id only) | `workspace` | System-level. |
|
||||||
| `update-workspace` | `workspace_record` | `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. |
|
| `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. |
|
| `create-api-key` | `key`, `workspace` (optional integrity check) | `api_key_plaintext`, `api_key` | Plaintext returned **once**; only hash stored. `key.name` required. |
|
||||||
| `list-api-keys` | `workspace`, `user_id` | `api_keys` | |
|
| `list-api-keys` | `user_id`, `workspace` (optional integrity check) | `api_keys` | |
|
||||||
| `revoke-api-key` | `workspace`, `key_id` | — | Deletes the key record. |
|
| `revoke-api-key` | `key_id`, `workspace` (optional integrity check) | — | Deletes the key record. |
|
||||||
| `get-signing-key-public` | — | `signing_key_public` | Gateway fetches this at startup. |
|
| `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). |
|
| `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). |
|
| `bootstrap` | — | `bootstrap_admin_user_id`, `bootstrap_admin_api_key` | If IAM tables are empty and the service is in `bootstrap` mode, creates the initial `default` workspace, an `admin` user, an initial API key, and an initial signing key; returns them once. Otherwise returns a masked auth failure. |
|
||||||
|
| `bootstrap-status` | — | `bootstrap_available` | Side-effect-free probe; `true` iff iam-svc is in `bootstrap` mode and tables are empty. Intended for first-run UX. |
|
||||||
|
|
||||||
## Error taxonomy
|
## Error taxonomy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,26 @@ The gateway forwards this to the IAM service, which validates
|
||||||
credentials and returns a signed JWT. The gateway returns the JWT to
|
credentials and returns a signed JWT. The gateway returns the JWT to
|
||||||
the caller.
|
the caller.
|
||||||
|
|
||||||
|
#### Self-service: `whoami` and `bootstrap-status`
|
||||||
|
|
||||||
|
Two side-effect-free probes that exist to support UI affordances
|
||||||
|
without giving the caller broad read access:
|
||||||
|
|
||||||
|
- `POST /api/v1/iam` with `{"operation": "whoami"}` — authenticated
|
||||||
|
only. Returns the caller's own user record (id, username, name,
|
||||||
|
email, workspace, roles, enabled, must_change_password,
|
||||||
|
created). No `users:read` capability is required, because every
|
||||||
|
authenticated caller can read themselves. The gateway populates
|
||||||
|
`actor` on the request from the authenticated identity, so the
|
||||||
|
regime resolves "the caller" without taking a target argument.
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/bootstrap-status` — public, side-effect-free.
|
||||||
|
Returns `{"bootstrap_available": true|false}`. `true` iff
|
||||||
|
iam-svc is in `bootstrap` mode and its tables are empty (i.e. an
|
||||||
|
unconsumed `bootstrap` call would currently succeed). Exists so
|
||||||
|
a first-run UI can decide whether to render the setup flow
|
||||||
|
without invoking the consuming `bootstrap` op.
|
||||||
|
|
||||||
#### IAM service delegation
|
#### IAM service delegation
|
||||||
|
|
||||||
The gateway stays thin. Its authentication logic is:
|
The gateway stays thin. Its authentication logic is:
|
||||||
|
|
@ -387,9 +407,10 @@ workspace; every `authorise` call sees a concrete value.
|
||||||
Whether the resolved workspace is permitted to be operated on by
|
Whether the resolved workspace is permitted to be operated on by
|
||||||
this caller is an **IAM decision**, not a gateway one. The gateway
|
this caller is an **IAM decision**, not a gateway one. The gateway
|
||||||
calls `authorise(identity, capability, {workspace: ..., ...})` and
|
calls `authorise(identity, capability, {workspace: ..., ...})` and
|
||||||
relays the answer. In the OSS regime, the answer comes from the
|
relays the answer. In the OSS regime, the regime checks whether
|
||||||
caller's role × workspace-scope — see [`capabilities.md`](capabilities.md).
|
the caller's permission grants for `<capability>` include this
|
||||||
In other regimes it could come from group mappings, policies,
|
workspace — see [`capabilities.md`](capabilities.md). In other
|
||||||
|
regimes the decision could come from group mappings, policies,
|
||||||
relationship tuples, or anything else the regime models.
|
relationship tuples, or anything else the regime models.
|
||||||
|
|
||||||
### Request anatomy
|
### Request anatomy
|
||||||
|
|
@ -500,8 +521,19 @@ The OSS regime ships three roles:
|
||||||
| `writer` | All reader capabilities, plus `graph:write`, `documents:write`, `rows:write`, `knowledge:write`, `collections:write`. |
|
| `writer` | All reader capabilities, plus `graph:write`, `documents:write`, `rows:write`, `knowledge:write`, `collections:write`. |
|
||||||
| `admin` | All writer capabilities, plus `config:write`, `flows:write`, `users:read`, `users:write`, `users:admin`, `keys:admin`, `workspaces:admin`, `iam:admin`, `metrics:read`. |
|
| `admin` | All writer capabilities, plus `config:write`, `flows:write`, `users:read`, `users:write`, `users:admin`, `keys:admin`, `workspaces:admin`, `iam:admin`, `metrics:read`. |
|
||||||
|
|
||||||
Workspace scope: `reader` and `writer` are active only in the
|
Workspace scope is a property of the *grant*, not of the user or
|
||||||
caller's bound workspace; `admin` is active across all workspaces.
|
role. In the OSS regime each capability granted by `reader` /
|
||||||
|
`writer` is scoped to the workspace the user record is associated
|
||||||
|
with; capabilities granted by `admin` are scoped to `*` (every
|
||||||
|
workspace). A user is a system-level object — they don't "live
|
||||||
|
in" a workspace, they hold permissions whose scope happens to
|
||||||
|
reference one.
|
||||||
|
|
||||||
|
The OSS regime is deliberately limited to one workspace association
|
||||||
|
per user; future regimes are free to grant the same user different
|
||||||
|
permissions in different workspaces, or use a non-workspace scope
|
||||||
|
entirely. This is regime-internal — neither the contract nor the
|
||||||
|
gateway carries an assumption either way.
|
||||||
|
|
||||||
The gateway gates each endpoint by *capability*, not by role.
|
The gateway gates each endpoint by *capability*, not by role.
|
||||||
Capabilities are declared per operation in the gateway's operation
|
Capabilities are declared per operation in the gateway's operation
|
||||||
|
|
@ -647,6 +679,9 @@ For HTTP requests:
|
||||||
error, fail closed (401 / 503 per deployment).
|
error, fail closed (401 / 503 per deployment).
|
||||||
8. Cache the decision per the contract's caching rules (clamped
|
8. Cache the decision per the contract's caching rules (clamped
|
||||||
above by a deployment-set ceiling).
|
above by a deployment-set ceiling).
|
||||||
|
9. For requests forwarded to iam-svc, set `actor` on the body
|
||||||
|
from `identity.handle`, overwriting any caller-supplied value.
|
||||||
|
See [`iam-contract.md`](iam-contract.md#actor-injection).
|
||||||
|
|
||||||
For WebSocket connections:
|
For WebSocket connections:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,27 @@ class IamClient(RequestResponse):
|
||||||
)
|
)
|
||||||
return resp.bootstrap_admin_user_id, resp.bootstrap_admin_api_key
|
return resp.bootstrap_admin_user_id, resp.bootstrap_admin_api_key
|
||||||
|
|
||||||
|
async def bootstrap_status(self, timeout=IAM_TIMEOUT):
|
||||||
|
"""Returns whether an unconsumed ``bootstrap`` call would
|
||||||
|
currently succeed (i.e. iam-svc is in ``bootstrap`` mode and
|
||||||
|
its tables are empty). Side-effect-free; intended for first-
|
||||||
|
run UX so a UI can decide whether to render setup."""
|
||||||
|
resp = await self._request(
|
||||||
|
operation="bootstrap-status", timeout=timeout,
|
||||||
|
)
|
||||||
|
return resp.bootstrap_available
|
||||||
|
|
||||||
|
async def whoami(self, actor, timeout=IAM_TIMEOUT):
|
||||||
|
"""Return the user record for ``actor`` (the authenticated
|
||||||
|
caller's handle). AUTHENTICATED-only; no capability check —
|
||||||
|
every authenticated user can read themselves."""
|
||||||
|
resp = await self._request(
|
||||||
|
operation="whoami",
|
||||||
|
actor=actor,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return resp.user
|
||||||
|
|
||||||
async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT):
|
async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT):
|
||||||
"""Resolve a plaintext API key to its identity triple.
|
"""Resolve a plaintext API key to its identity triple.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,10 @@ class IamResponseTranslator(MessageTranslator):
|
||||||
result["bootstrap_admin_user_id"] = obj.bootstrap_admin_user_id
|
result["bootstrap_admin_user_id"] = obj.bootstrap_admin_user_id
|
||||||
if obj.bootstrap_admin_api_key:
|
if obj.bootstrap_admin_api_key:
|
||||||
result["bootstrap_admin_api_key"] = obj.bootstrap_admin_api_key
|
result["bootstrap_admin_api_key"] = obj.bootstrap_admin_api_key
|
||||||
|
# bootstrap-status: emit unconditionally — the false case is
|
||||||
|
# meaningful for UIs deciding whether to render first-run
|
||||||
|
# setup, so it can't be dropped by a truthy-only filter.
|
||||||
|
result["bootstrap_available"] = bool(obj.bootstrap_available)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,10 @@ class IamResponse:
|
||||||
bootstrap_admin_user_id: str = ""
|
bootstrap_admin_user_id: str = ""
|
||||||
bootstrap_admin_api_key: str = ""
|
bootstrap_admin_api_key: str = ""
|
||||||
|
|
||||||
|
# bootstrap-status — true iff iam-svc is in 'bootstrap' mode with
|
||||||
|
# empty tables, i.e. an unconsumed bootstrap call would succeed.
|
||||||
|
bootstrap_available: bool = False
|
||||||
|
|
||||||
# ---- authorise / authorise-many outputs ----
|
# ---- authorise / authorise-many outputs ----
|
||||||
# authorise: the regime's allow / deny verdict.
|
# authorise: the regime's allow / deny verdict.
|
||||||
decision_allow: bool = False
|
decision_allow: bool = False
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ tg-bootstrap-iam = "trustgraph.cli.bootstrap_iam:main"
|
||||||
tg-login = "trustgraph.cli.login:main"
|
tg-login = "trustgraph.cli.login:main"
|
||||||
tg-create-user = "trustgraph.cli.create_user:main"
|
tg-create-user = "trustgraph.cli.create_user:main"
|
||||||
tg-list-users = "trustgraph.cli.list_users:main"
|
tg-list-users = "trustgraph.cli.list_users:main"
|
||||||
|
tg-whoami = "trustgraph.cli.whoami:main"
|
||||||
|
tg-update-user = "trustgraph.cli.update_user:main"
|
||||||
tg-disable-user = "trustgraph.cli.disable_user:main"
|
tg-disable-user = "trustgraph.cli.disable_user:main"
|
||||||
tg-enable-user = "trustgraph.cli.enable_user:main"
|
tg-enable-user = "trustgraph.cli.enable_user:main"
|
||||||
tg-delete-user = "trustgraph.cli.delete_user:main"
|
tg-delete-user = "trustgraph.cli.delete_user:main"
|
||||||
|
|
|
||||||
125
trustgraph-cli/trustgraph/cli/update_user.py
Normal file
125
trustgraph-cli/trustgraph/cli/update_user.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
Update a user's profile fields: name, email, roles, enabled flag,
|
||||||
|
must-change-password flag.
|
||||||
|
|
||||||
|
Username is immutable — create a new user and disable the old one
|
||||||
|
to effect a username change. Password changes go through
|
||||||
|
``tg-change-password`` (self-service) or ``tg-reset-password``
|
||||||
|
(admin-driven).
|
||||||
|
|
||||||
|
Only the fields you supply are changed; omitted fields are left
|
||||||
|
untouched on the user record. An empty ``--roles`` is rejected by
|
||||||
|
iam-svc (a user must have at least one role); to demote a user use
|
||||||
|
``tg-disable-user``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(s):
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
s = s.strip().lower()
|
||||||
|
if s in ("yes", "y", "true", "t", "1"):
|
||||||
|
return True
|
||||||
|
if s in ("no", "n", "false", "f", "0"):
|
||||||
|
return False
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"expected yes/no, got {s!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def do_update_user(args):
|
||||||
|
user = {}
|
||||||
|
if args.name is not None:
|
||||||
|
user["name"] = args.name
|
||||||
|
if args.email is not None:
|
||||||
|
user["email"] = args.email
|
||||||
|
if args.roles is not None:
|
||||||
|
user["roles"] = args.roles
|
||||||
|
if args.enabled is not None:
|
||||||
|
user["enabled"] = args.enabled
|
||||||
|
if args.must_change_password is not None:
|
||||||
|
user["must_change_password"] = args.must_change_password
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(
|
||||||
|
"tg-update-user: nothing to change — supply at least "
|
||||||
|
"one of --name / --email / --roles / --enabled / "
|
||||||
|
"--must-change-password",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
req = {
|
||||||
|
"operation": "update-user",
|
||||||
|
"user_id": args.user_id,
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
if args.workspace:
|
||||||
|
req["workspace"] = args.workspace
|
||||||
|
resp = call_iam(args.api_url, args.token, req)
|
||||||
|
|
||||||
|
rec = resp.get("user", {})
|
||||||
|
print(f"id : {rec.get('id', '')}")
|
||||||
|
print(f"username : {rec.get('username', '')}")
|
||||||
|
print(f"name : {rec.get('name', '')}")
|
||||||
|
print(f"email : {rec.get('email', '')}")
|
||||||
|
print(f"workspace : {rec.get('workspace', '')}")
|
||||||
|
print(f"roles : {', '.join(rec.get('roles', []))}")
|
||||||
|
print(f"enabled : {'yes' if rec.get('enabled') else 'no'}")
|
||||||
|
print(
|
||||||
|
f"must-change-pw: "
|
||||||
|
f"{'yes' if rec.get('must_change_password') else 'no'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="tg-update-user", description=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-u", "--api-url", default=DEFAULT_URL,
|
||||||
|
help=f"API URL (default: {DEFAULT_URL})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t", "--token", default=DEFAULT_TOKEN,
|
||||||
|
help="Auth token (default: $TRUSTGRAPH_TOKEN)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-id", required=True, help="Target user id",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name", default=None, help="New display name",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--email", default=None, help="New email",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--roles", nargs="+", default=None,
|
||||||
|
help="Replacement role list (e.g. --roles reader writer)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--enabled", type=_parse_bool, default=None,
|
||||||
|
help="Set enabled flag (yes/no)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--must-change-password", type=_parse_bool, default=None,
|
||||||
|
help="Set must-change-password flag (yes/no)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-w", "--workspace", default=None,
|
||||||
|
help=(
|
||||||
|
"Optional workspace integrity check — when supplied, "
|
||||||
|
"iam-svc verifies the target user's home workspace "
|
||||||
|
"matches"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
run_main(do_update_user, parser)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
52
trustgraph-cli/trustgraph/cli/whoami.py
Normal file
52
trustgraph-cli/trustgraph/cli/whoami.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""
|
||||||
|
Show the authenticated caller's own user record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import tabulate
|
||||||
|
|
||||||
|
from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
|
||||||
|
|
||||||
|
|
||||||
|
def do_whoami(args):
|
||||||
|
resp = call_iam(args.api_url, args.token, {"operation": "whoami"})
|
||||||
|
user = resp.get("user")
|
||||||
|
if not user:
|
||||||
|
print("(no user record returned)")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
["id", user.get("id", "")],
|
||||||
|
["username", user.get("username", "")],
|
||||||
|
["name", user.get("name", "")],
|
||||||
|
["email", user.get("email", "")],
|
||||||
|
["workspace", user.get("workspace", "")],
|
||||||
|
["roles", ", ".join(user.get("roles", []))],
|
||||||
|
["enabled", "yes" if user.get("enabled") else "no"],
|
||||||
|
[
|
||||||
|
"must change password",
|
||||||
|
"yes" if user.get("must_change_password") else "no",
|
||||||
|
],
|
||||||
|
["created", user.get("created", "")],
|
||||||
|
]
|
||||||
|
print(tabulate.tabulate(rows, tablefmt="plain"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="tg-whoami", description=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-u", "--api-url", default=DEFAULT_URL,
|
||||||
|
help=f"API URL (default: {DEFAULT_URL})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t", "--token", default=DEFAULT_TOKEN,
|
||||||
|
help="Auth token (default: $TRUSTGRAPH_TOKEN)",
|
||||||
|
)
|
||||||
|
run_main(do_whoami, parser)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -123,14 +123,31 @@ class Mux:
|
||||||
|
|
||||||
# Per-service capability gating. Resolved through the
|
# Per-service capability gating. Resolved through the
|
||||||
# operation registry so the WS path matches what HTTP
|
# operation registry so the WS path matches what HTTP
|
||||||
# callers see — same authority, same caps. Service
|
# callers see — same authority, same caps.
|
||||||
# kinds that aren't registered are refused.
|
#
|
||||||
|
# Lookup mirrors the HTTP routing decision in
|
||||||
|
# ``request_task``: presence of ``flow`` on the envelope
|
||||||
|
# means a flow-level data-plane service (graph-rag,
|
||||||
|
# agent, …); absence means a workspace-level service
|
||||||
|
# (config, flow management, librarian, …) whose specific
|
||||||
|
# operation is in the inner request body. ``iam`` is
|
||||||
|
# treated as workspace-level too — its operations are
|
||||||
|
# registered with bare names, no kind prefix.
|
||||||
from ..registry import lookup as _registry_lookup
|
from ..registry import lookup as _registry_lookup
|
||||||
from ..capabilities import enforce_workspace
|
from ..capabilities import enforce_workspace
|
||||||
from aiohttp import web as _web
|
from aiohttp import web as _web
|
||||||
|
|
||||||
service = data.get("service", "")
|
service = data.get("service", "")
|
||||||
op = _registry_lookup(f"flow-service:{service}")
|
inner = data.get("request") or {}
|
||||||
|
inner_op = inner.get("operation", "") if isinstance(inner, dict) else ""
|
||||||
|
|
||||||
|
if data.get("flow"):
|
||||||
|
op = _registry_lookup(f"flow-service:{service}")
|
||||||
|
elif service == "iam":
|
||||||
|
op = _registry_lookup(inner_op) if inner_op else None
|
||||||
|
else:
|
||||||
|
op = _registry_lookup(f"{service}:{inner_op}") if inner_op else None
|
||||||
|
|
||||||
if op is None:
|
if op is None:
|
||||||
await self.ws.send_json({
|
await self.ws.send_json({
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
|
|
@ -142,23 +159,36 @@ class Mux:
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Workspace + flow form the resource address for a
|
# Resolve workspace first (default-fill from the caller's
|
||||||
# flow-level service call. Resolve workspace first
|
# bound workspace), then ask the regime to authorise the
|
||||||
# (default-fill from the caller's bound workspace),
|
# service-level capability against the matched
|
||||||
# then ask the regime to authorise the service-level
|
# operation's resource shape.
|
||||||
# capability against that {workspace, flow} resource.
|
|
||||||
try:
|
try:
|
||||||
await enforce_workspace(data, self.identity, self.auth)
|
await enforce_workspace(data, self.identity, self.auth)
|
||||||
inner = data.get("request")
|
|
||||||
if isinstance(inner, dict):
|
if isinstance(inner, dict):
|
||||||
await enforce_workspace(inner, self.identity, self.auth)
|
await enforce_workspace(inner, self.identity, self.auth)
|
||||||
|
|
||||||
resource = {
|
if data.get("flow"):
|
||||||
"workspace": data.get("workspace", ""),
|
resource = {
|
||||||
"flow": data.get("flow", ""),
|
"workspace": data.get("workspace", ""),
|
||||||
}
|
"flow": data.get("flow", ""),
|
||||||
|
}
|
||||||
|
parameters = {}
|
||||||
|
else:
|
||||||
|
# Build a minimal RequestContext so the matched
|
||||||
|
# operation's own extractors decide resource and
|
||||||
|
# parameters — same path the HTTP endpoints take.
|
||||||
|
from ..registry import RequestContext
|
||||||
|
ctx = RequestContext(
|
||||||
|
body=inner if isinstance(inner, dict) else {},
|
||||||
|
match_info={},
|
||||||
|
identity=self.identity,
|
||||||
|
)
|
||||||
|
resource = op.extract_resource(ctx)
|
||||||
|
parameters = op.extract_parameters(ctx)
|
||||||
|
|
||||||
await self.auth.authorise(
|
await self.auth.authorise(
|
||||||
self.identity, op.capability, resource, {},
|
self.identity, op.capability, resource, parameters,
|
||||||
)
|
)
|
||||||
except _web.HTTPForbidden:
|
except _web.HTTPForbidden:
|
||||||
await self.ws.send_json({
|
await self.ws.send_json({
|
||||||
|
|
@ -183,6 +213,17 @@ class Mux:
|
||||||
|
|
||||||
workspace = data["workspace"]
|
workspace = data["workspace"]
|
||||||
|
|
||||||
|
# Plumb authenticated caller's handle as ``actor`` so
|
||||||
|
# iam-svc handlers (whoami, future actor-scoped checks)
|
||||||
|
# know who is calling. Overwrite any caller-supplied
|
||||||
|
# value so it can't be spoofed over the WS.
|
||||||
|
if (
|
||||||
|
service == "iam"
|
||||||
|
and isinstance(data.get("request"), dict)
|
||||||
|
and self.identity is not None
|
||||||
|
):
|
||||||
|
data["request"]["actor"] = self.identity.handle
|
||||||
|
|
||||||
await self.q.put((
|
await self.q.put((
|
||||||
data["id"],
|
data["id"],
|
||||||
workspace,
|
workspace,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ class AuthEndpoints:
|
||||||
app.add_routes([
|
app.add_routes([
|
||||||
web.post("/api/v1/auth/login", self.login),
|
web.post("/api/v1/auth/login", self.login),
|
||||||
web.post("/api/v1/auth/bootstrap", self.bootstrap),
|
web.post("/api/v1/auth/bootstrap", self.bootstrap),
|
||||||
|
web.post(
|
||||||
|
"/api/v1/auth/bootstrap-status",
|
||||||
|
self.bootstrap_status,
|
||||||
|
),
|
||||||
web.post(
|
web.post(
|
||||||
"/api/v1/auth/change-password",
|
"/api/v1/auth/change-password",
|
||||||
self.change_password,
|
self.change_password,
|
||||||
|
|
@ -83,6 +87,18 @@ class AuthEndpoints:
|
||||||
)
|
)
|
||||||
return web.json_response(resp)
|
return web.json_response(resp)
|
||||||
|
|
||||||
|
async def bootstrap_status(self, request):
|
||||||
|
"""Public, side-effect-free. Returns ``{"bootstrap_available":
|
||||||
|
bool}`` so a UI can decide whether to render first-run setup
|
||||||
|
without invoking the consuming ``bootstrap`` op."""
|
||||||
|
await enforce(request, self.auth, PUBLIC)
|
||||||
|
resp = await self._forward({"operation": "bootstrap-status"})
|
||||||
|
if "error" in resp:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "auth failure"}, status=401,
|
||||||
|
)
|
||||||
|
return web.json_response(resp)
|
||||||
|
|
||||||
async def change_password(self, request):
|
async def change_password(self, request):
|
||||||
"""Authenticated (any role). Accepts {current_password,
|
"""Authenticated (any role). Accepts {current_password,
|
||||||
new_password}; user_id is taken from the authenticated
|
new_password}; user_id is taken from the authenticated
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,14 @@ class IamEndpoint:
|
||||||
identity, op.capability, resource, parameters,
|
identity, op.capability, resource, parameters,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Plumb the authenticated caller's handle through as ``actor``
|
||||||
|
# so iam-svc handlers (e.g. whoami, future actor-scoped
|
||||||
|
# checks) know who is making the request. The gateway is
|
||||||
|
# the only authority for this — body-supplied ``actor``
|
||||||
|
# values are overwritten so callers can't impersonate.
|
||||||
|
if identity is not None:
|
||||||
|
body["actor"] = identity.handle
|
||||||
|
|
||||||
async def responder(x, fin):
|
async def responder(x, fin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,27 +271,31 @@ register(Operation(
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
# API keys: workspace-level resource — keys live within a workspace.
|
# API keys: SYSTEM-level resource — like users, a key record exists
|
||||||
|
# in the deployment-wide keys registry. The workspace the key
|
||||||
|
# authenticates to is a property of the record, not a containment;
|
||||||
|
# it appears as a parameter so the regime can scope the admin's
|
||||||
|
# authority to issue / list / revoke against it.
|
||||||
register(Operation(
|
register(Operation(
|
||||||
name="create-api-key",
|
name="create-api-key",
|
||||||
capability="keys:admin",
|
capability="keys:admin",
|
||||||
resource_level=ResourceLevel.WORKSPACE,
|
resource_level=ResourceLevel.SYSTEM,
|
||||||
extract_resource=_workspace_from_body,
|
extract_resource=_empty_resource,
|
||||||
extract_parameters=_no_parameters,
|
extract_parameters=_workspace_param_only,
|
||||||
))
|
))
|
||||||
register(Operation(
|
register(Operation(
|
||||||
name="list-api-keys",
|
name="list-api-keys",
|
||||||
capability="keys:admin",
|
capability="keys:admin",
|
||||||
resource_level=ResourceLevel.WORKSPACE,
|
resource_level=ResourceLevel.SYSTEM,
|
||||||
extract_resource=_workspace_from_body,
|
extract_resource=_empty_resource,
|
||||||
extract_parameters=_no_parameters,
|
extract_parameters=_workspace_param_only,
|
||||||
))
|
))
|
||||||
register(Operation(
|
register(Operation(
|
||||||
name="revoke-api-key",
|
name="revoke-api-key",
|
||||||
capability="keys:admin",
|
capability="keys:admin",
|
||||||
resource_level=ResourceLevel.WORKSPACE,
|
resource_level=ResourceLevel.SYSTEM,
|
||||||
extract_resource=_workspace_from_body,
|
extract_resource=_empty_resource,
|
||||||
extract_parameters=_no_parameters,
|
extract_parameters=_workspace_param_only,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -370,6 +374,13 @@ register(Operation(
|
||||||
extract_resource=_empty_resource,
|
extract_resource=_empty_resource,
|
||||||
extract_parameters=_no_parameters,
|
extract_parameters=_no_parameters,
|
||||||
))
|
))
|
||||||
|
register(Operation(
|
||||||
|
name="bootstrap-status",
|
||||||
|
capability=PUBLIC,
|
||||||
|
resource_level=ResourceLevel.SYSTEM,
|
||||||
|
extract_resource=_empty_resource,
|
||||||
|
extract_parameters=_no_parameters,
|
||||||
|
))
|
||||||
register(Operation(
|
register(Operation(
|
||||||
name="change-password",
|
name="change-password",
|
||||||
capability=AUTHENTICATED,
|
capability=AUTHENTICATED,
|
||||||
|
|
@ -377,6 +388,13 @@ register(Operation(
|
||||||
extract_resource=_empty_resource,
|
extract_resource=_empty_resource,
|
||||||
extract_parameters=_no_parameters,
|
extract_parameters=_no_parameters,
|
||||||
))
|
))
|
||||||
|
register(Operation(
|
||||||
|
name="whoami",
|
||||||
|
capability=AUTHENTICATED,
|
||||||
|
resource_level=ResourceLevel.SYSTEM,
|
||||||
|
extract_resource=_empty_resource,
|
||||||
|
extract_parameters=_no_parameters,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,10 @@ class IamService:
|
||||||
try:
|
try:
|
||||||
if op == "bootstrap":
|
if op == "bootstrap":
|
||||||
return await self.handle_bootstrap(v)
|
return await self.handle_bootstrap(v)
|
||||||
|
if op == "bootstrap-status":
|
||||||
|
return await self.handle_bootstrap_status(v)
|
||||||
|
if op == "whoami":
|
||||||
|
return await self.handle_whoami(v)
|
||||||
if op == "resolve-api-key":
|
if op == "resolve-api-key":
|
||||||
return await self.handle_resolve_api_key(v)
|
return await self.handle_resolve_api_key(v)
|
||||||
if op == "create-user":
|
if op == "create-user":
|
||||||
|
|
@ -483,6 +487,39 @@ class IamService:
|
||||||
bootstrap_admin_api_key=plaintext,
|
bootstrap_admin_api_key=plaintext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def handle_whoami(self, v):
|
||||||
|
"""Return the caller's own user record. ``v.actor`` is the
|
||||||
|
authenticated identity's handle (the gateway populates it
|
||||||
|
from ``identity.handle``). No ``users:read`` capability
|
||||||
|
required — every authenticated user can read themselves."""
|
||||||
|
if not v.actor:
|
||||||
|
return _err(
|
||||||
|
"invalid-argument",
|
||||||
|
"actor required (gateway should populate this)",
|
||||||
|
)
|
||||||
|
user_row = await self.table_store.get_user(v.actor)
|
||||||
|
if user_row is None:
|
||||||
|
return _err("not-found", "user not found")
|
||||||
|
return IamResponse(user=self._row_to_user_record(user_row))
|
||||||
|
|
||||||
|
async def handle_bootstrap_status(self, v):
|
||||||
|
"""Probe op: returns whether the deployment is currently in
|
||||||
|
the unconsumed-bootstrap state (i.e. ``bootstrap`` mode with
|
||||||
|
empty tables, where an explicit ``bootstrap`` call would
|
||||||
|
succeed). PUBLIC so a UI can decide whether to render the
|
||||||
|
first-run setup flow without invoking the side-effectful
|
||||||
|
``bootstrap`` op.
|
||||||
|
|
||||||
|
The information leaked is intentionally narrow: an empty
|
||||||
|
deployment in bootstrap mode is already inferable (no users,
|
||||||
|
no logins succeed); this just makes the answer explicit
|
||||||
|
instead of forcing callers to probe the masked-failure path."""
|
||||||
|
available = (
|
||||||
|
self.bootstrap_mode == "bootstrap"
|
||||||
|
and not await self.table_store.any_workspace_exists()
|
||||||
|
)
|
||||||
|
return IamResponse(bootstrap_available=available)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Signing key helpers
|
# Signing key helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -612,15 +649,22 @@ class IamService:
|
||||||
created=_iso(created),
|
created=_iso(created),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _user_in_workspace(self, user_id, workspace):
|
async def _resolve_user(self, user_id, workspace=None):
|
||||||
"""Return (user_row, error_response_or_None). Loads the user
|
"""Return (user_row, error_response_or_None). Loads the user
|
||||||
record, verifies it exists, is enabled, and belongs to
|
record by id and (when ``workspace`` is supplied) verifies the
|
||||||
``workspace``. The workspace scope check rejects cross-
|
record's home workspace matches.
|
||||||
workspace admin attempts."""
|
|
||||||
|
Workspace is an *optional integrity check* — the user record
|
||||||
|
is system-level, identified by id alone. If the caller asserts
|
||||||
|
a workspace, we verify; if they omit it, we just return the
|
||||||
|
record. Authorisation (whether the caller is permitted to
|
||||||
|
operate on this user) is the gateway's responsibility via the
|
||||||
|
contract's ``authorise`` call before the handler is reached.
|
||||||
|
"""
|
||||||
user_row = await self.table_store.get_user(user_id)
|
user_row = await self.table_store.get_user(user_id)
|
||||||
if user_row is None:
|
if user_row is None:
|
||||||
return None, _err("not-found", "user not found")
|
return None, _err("not-found", "user not found")
|
||||||
if user_row[1] != workspace:
|
if workspace and user_row[1] != workspace:
|
||||||
return None, _err(
|
return None, _err(
|
||||||
"operation-not-permitted",
|
"operation-not-permitted",
|
||||||
"user is in a different workspace",
|
"user is in a different workspace",
|
||||||
|
|
@ -665,15 +709,10 @@ class IamService:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_reset_password(self, v):
|
async def handle_reset_password(self, v):
|
||||||
if not v.workspace:
|
|
||||||
return _err(
|
|
||||||
"invalid-argument",
|
|
||||||
"workspace required for reset-password",
|
|
||||||
)
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err("invalid-argument", "user_id required")
|
return _err("invalid-argument", "user_id required")
|
||||||
|
|
||||||
_, err = await self._user_in_workspace(v.user_id, v.workspace)
|
_, err = await self._resolve_user(v.user_id, v.workspace or None)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
|
|
@ -690,13 +729,11 @@ class IamService:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_get_user(self, v):
|
async def handle_get_user(self, v):
|
||||||
if not v.workspace:
|
|
||||||
return _err("invalid-argument", "workspace required")
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err("invalid-argument", "user_id required")
|
return _err("invalid-argument", "user_id required")
|
||||||
|
|
||||||
user_row, err = await self._user_in_workspace(
|
user_row, err = await self._resolve_user(
|
||||||
v.user_id, v.workspace,
|
v.user_id, v.workspace or None,
|
||||||
)
|
)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
|
|
@ -707,8 +744,6 @@ class IamService:
|
||||||
must_change_password. Username is immutable — change it by
|
must_change_password. Username is immutable — change it by
|
||||||
creating a new user and disabling the old one. Password
|
creating a new user and disabling the old one. Password
|
||||||
changes go through change-password / reset-password."""
|
changes go through change-password / reset-password."""
|
||||||
if not v.workspace:
|
|
||||||
return _err("invalid-argument", "workspace required")
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err("invalid-argument", "user_id required")
|
return _err("invalid-argument", "user_id required")
|
||||||
if v.user is None:
|
if v.user is None:
|
||||||
|
|
@ -719,25 +754,17 @@ class IamService:
|
||||||
"password cannot be changed via update-user; "
|
"password cannot be changed via update-user; "
|
||||||
"use change-password or reset-password",
|
"use change-password or reset-password",
|
||||||
)
|
)
|
||||||
if v.user.username and v.user.username != "":
|
|
||||||
# Compare to existing. Username-change not allowed.
|
existing, err = await self._resolve_user(
|
||||||
existing, err = await self._user_in_workspace(
|
v.user_id, v.workspace or None,
|
||||||
v.user_id, v.workspace,
|
)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
if v.user.username and v.user.username != existing[2]:
|
||||||
|
return _err(
|
||||||
|
"invalid-argument",
|
||||||
|
"username is immutable; create a new user instead",
|
||||||
)
|
)
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
if v.user.username != existing[2]:
|
|
||||||
return _err(
|
|
||||||
"invalid-argument",
|
|
||||||
"username is immutable; create a new user "
|
|
||||||
"instead",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
existing, err = await self._user_in_workspace(
|
|
||||||
v.user_id, v.workspace,
|
|
||||||
)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
# Carry forward fields the caller didn't provide.
|
# Carry forward fields the caller didn't provide.
|
||||||
(
|
(
|
||||||
|
|
@ -774,12 +801,10 @@ class IamService:
|
||||||
async def handle_disable_user(self, v):
|
async def handle_disable_user(self, v):
|
||||||
"""Soft-delete: set enabled=false and revoke every API key
|
"""Soft-delete: set enabled=false and revoke every API key
|
||||||
belonging to the user."""
|
belonging to the user."""
|
||||||
if not v.workspace:
|
|
||||||
return _err("invalid-argument", "workspace required")
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err("invalid-argument", "user_id required")
|
return _err("invalid-argument", "user_id required")
|
||||||
|
|
||||||
_, err = await self._user_in_workspace(v.user_id, v.workspace)
|
_, err = await self._resolve_user(v.user_id, v.workspace or None)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
|
|
@ -797,12 +822,10 @@ class IamService:
|
||||||
async def handle_enable_user(self, v):
|
async def handle_enable_user(self, v):
|
||||||
"""Re-enable a previously disabled user. Does not restore
|
"""Re-enable a previously disabled user. Does not restore
|
||||||
API keys — those have to be re-issued by the admin."""
|
API keys — those have to be re-issued by the admin."""
|
||||||
if not v.workspace:
|
|
||||||
return _err("invalid-argument", "workspace required")
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err("invalid-argument", "user_id required")
|
return _err("invalid-argument", "user_id required")
|
||||||
|
|
||||||
_, err = await self._user_in_workspace(v.user_id, v.workspace)
|
_, err = await self._resolve_user(v.user_id, v.workspace or None)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
|
|
@ -821,29 +844,30 @@ class IamService:
|
||||||
cover GDPR erasure-style requirements). When audit logging
|
cover GDPR erasure-style requirements). When audit logging
|
||||||
lands, the decision to delete vs. anonymise referenced audit
|
lands, the decision to delete vs. anonymise referenced audit
|
||||||
rows will need to be revisited."""
|
rows will need to be revisited."""
|
||||||
if not v.workspace:
|
|
||||||
return _err("invalid-argument", "workspace required")
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err("invalid-argument", "user_id required")
|
return _err("invalid-argument", "user_id required")
|
||||||
|
|
||||||
user_row, err = await self._user_in_workspace(
|
user_row, err = await self._resolve_user(
|
||||||
v.user_id, v.workspace,
|
v.user_id, v.workspace or None,
|
||||||
)
|
)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
# user_row indices match get_user columns. Username is [2].
|
# user_row indices match get_user columns. Username is [2].
|
||||||
username = user_row[2]
|
username = user_row[2]
|
||||||
|
record_workspace = user_row[1]
|
||||||
|
|
||||||
# Revoke all API keys.
|
# Revoke all API keys.
|
||||||
key_rows = await self.table_store.list_api_keys_by_user(v.user_id)
|
key_rows = await self.table_store.list_api_keys_by_user(v.user_id)
|
||||||
for kr in key_rows:
|
for kr in key_rows:
|
||||||
await self.table_store.delete_api_key(kr[0])
|
await self.table_store.delete_api_key(kr[0])
|
||||||
|
|
||||||
# Remove username lookup.
|
# Remove username lookup — keyed on (workspace, username),
|
||||||
|
# so use the resolved workspace from the user record rather
|
||||||
|
# than relying on the caller-supplied filter.
|
||||||
if username:
|
if username:
|
||||||
await self.table_store.delete_username_lookup(
|
await self.table_store.delete_username_lookup(
|
||||||
v.workspace, username,
|
record_workspace, username,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove user record.
|
# Remove user record.
|
||||||
|
|
@ -1098,12 +1122,15 @@ class IamService:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_list_users(self, v):
|
async def handle_list_users(self, v):
|
||||||
if not v.workspace:
|
# System-level operation: workspace, when supplied, is a
|
||||||
return _err(
|
# filter on the user record's home-workspace association.
|
||||||
"invalid-argument", "workspace required for list-users",
|
# Empty workspace returns the deployment-wide list — the
|
||||||
)
|
# gateway has already authorised the caller's authority to
|
||||||
|
# see that scope.
|
||||||
rows = await self.table_store.list_users_by_workspace(v.workspace)
|
if v.workspace:
|
||||||
|
rows = await self.table_store.list_users_by_workspace(v.workspace)
|
||||||
|
else:
|
||||||
|
rows = await self.table_store.list_users()
|
||||||
return IamResponse(
|
return IamResponse(
|
||||||
users=[self._row_to_user_record(r) for r in rows],
|
users=[self._row_to_user_record(r) for r in rows],
|
||||||
)
|
)
|
||||||
|
|
@ -1113,24 +1140,21 @@ class IamService:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_create_api_key(self, v):
|
async def handle_create_api_key(self, v):
|
||||||
if not v.workspace:
|
|
||||||
return _err(
|
|
||||||
"invalid-argument", "workspace required for create-api-key",
|
|
||||||
)
|
|
||||||
if v.key is None or not v.key.user_id:
|
if v.key is None or not v.key.user_id:
|
||||||
return _err("invalid-argument", "key.user_id required")
|
return _err("invalid-argument", "key.user_id required")
|
||||||
if not v.key.name:
|
if not v.key.name:
|
||||||
return _err("invalid-argument", "key.name required")
|
return _err("invalid-argument", "key.name required")
|
||||||
|
|
||||||
# Target user must exist and belong to the caller's workspace.
|
# API keys are system-level records with a workspace
|
||||||
user_row = await self.table_store.get_user(v.key.user_id)
|
# association (the user's home workspace). Workspace is an
|
||||||
if user_row is None:
|
# optional integrity check on the caller's request — when
|
||||||
return _err("not-found", "user not found")
|
# supplied it must match the target user's home workspace;
|
||||||
if user_row[1] != v.workspace:
|
# when omitted, the user's home workspace is used.
|
||||||
return _err(
|
user_row, err = await self._resolve_user(
|
||||||
"operation-not-permitted",
|
v.key.user_id, v.workspace or None,
|
||||||
"target user is in a different workspace",
|
)
|
||||||
)
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
plaintext = _generate_api_key()
|
plaintext = _generate_api_key()
|
||||||
key_id = str(uuid.uuid4())
|
key_id = str(uuid.uuid4())
|
||||||
|
|
@ -1161,20 +1185,15 @@ class IamService:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_list_api_keys(self, v):
|
async def handle_list_api_keys(self, v):
|
||||||
if not v.workspace:
|
|
||||||
return _err(
|
|
||||||
"invalid-argument",
|
|
||||||
"workspace required for list-api-keys",
|
|
||||||
)
|
|
||||||
if not v.user_id:
|
if not v.user_id:
|
||||||
return _err(
|
return _err(
|
||||||
"invalid-argument", "user_id required for list-api-keys",
|
"invalid-argument", "user_id required for list-api-keys",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Workspace-scope check: user must live in this workspace.
|
# Workspace is an optional integrity check.
|
||||||
user_row = await self.table_store.get_user(v.user_id)
|
_, err = await self._resolve_user(v.user_id, v.workspace or None)
|
||||||
if user_row is None or user_row[1] != v.workspace:
|
if err is not None:
|
||||||
return _err("not-found", "user not found in workspace")
|
return err
|
||||||
|
|
||||||
rows = await self.table_store.list_api_keys_by_user(v.user_id)
|
rows = await self.table_store.list_api_keys_by_user(v.user_id)
|
||||||
return IamResponse(
|
return IamResponse(
|
||||||
|
|
@ -1186,11 +1205,6 @@ class IamService:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_revoke_api_key(self, v):
|
async def handle_revoke_api_key(self, v):
|
||||||
if not v.workspace:
|
|
||||||
return _err(
|
|
||||||
"invalid-argument",
|
|
||||||
"workspace required for revoke-api-key",
|
|
||||||
)
|
|
||||||
if not v.key_id:
|
if not v.key_id:
|
||||||
return _err("invalid-argument", "key_id required")
|
return _err("invalid-argument", "key_id required")
|
||||||
|
|
||||||
|
|
@ -1199,13 +1213,15 @@ class IamService:
|
||||||
return _err("not-found", "api key not found")
|
return _err("not-found", "api key not found")
|
||||||
|
|
||||||
key_hash, _id, user_id, _name, _prefix, _expires, _c, _lu = row
|
key_hash, _id, user_id, _name, _prefix, _expires, _c, _lu = row
|
||||||
# Workspace-scope check via the owning user.
|
|
||||||
user_row = await self.table_store.get_user(user_id)
|
# Workspace is an optional integrity check via the owning user.
|
||||||
if user_row is None or user_row[1] != v.workspace:
|
if v.workspace:
|
||||||
return _err(
|
user_row = await self.table_store.get_user(user_id)
|
||||||
"operation-not-permitted",
|
if user_row is None or user_row[1] != v.workspace:
|
||||||
"key belongs to a different workspace",
|
return _err(
|
||||||
)
|
"operation-not-permitted",
|
||||||
|
"key belongs to a different workspace",
|
||||||
|
)
|
||||||
|
|
||||||
await self.table_store.delete_api_key(key_hash)
|
await self.table_store.delete_api_key(key_hash)
|
||||||
return IamResponse()
|
return IamResponse()
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,11 @@ class IamTableStore:
|
||||||
roles, enabled, must_change_password, created
|
roles, enabled, must_change_password, created
|
||||||
FROM iam_users WHERE workspace = ? ALLOW FILTERING
|
FROM iam_users WHERE workspace = ? ALLOW FILTERING
|
||||||
""")
|
""")
|
||||||
|
self.list_users_stmt = c.prepare("""
|
||||||
|
SELECT id, workspace, username, name, email, password_hash,
|
||||||
|
roles, enabled, must_change_password, created
|
||||||
|
FROM iam_users
|
||||||
|
""")
|
||||||
|
|
||||||
self.put_username_lookup_stmt = c.prepare("""
|
self.put_username_lookup_stmt = c.prepare("""
|
||||||
INSERT INTO iam_users_by_username (workspace, username, user_id)
|
INSERT INTO iam_users_by_username (workspace, username, user_id)
|
||||||
|
|
@ -304,6 +309,15 @@ class IamTableStore:
|
||||||
self.cassandra, self.list_users_by_workspace_stmt, (workspace,),
|
self.cassandra, self.list_users_by_workspace_stmt, (workspace,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def list_users(self):
|
||||||
|
"""List every user across the deployment. Used by the
|
||||||
|
system-level list-users handler when no workspace filter is
|
||||||
|
supplied; the gateway has already authorised the call against
|
||||||
|
the caller's authority."""
|
||||||
|
return await async_execute(
|
||||||
|
self.cassandra, self.list_users_stmt, (),
|
||||||
|
)
|
||||||
|
|
||||||
async def delete_user(self, id):
|
async def delete_user(self, id):
|
||||||
await async_execute(
|
await async_execute(
|
||||||
self.cassandra, self.delete_user_stmt, (id,),
|
self.cassandra, self.delete_user_stmt, (id,),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue