feat: global usernames and rename workspace to default_workspace

Users are global entities, not scoped to workspaces. This change:

Track A — Global usernames:
- Change iam_users_by_username to PRIMARY KEY (username), removing
  workspace from the lookup key
- Login looks up username globally, no workspace required
- Username uniqueness is enforced globally, not per-workspace
- Login -w now overrides the JWT workspace (session workspace)
  rather than selecting which user registry to search

Track B — Rename workspace to default_workspace:
- UserRecord.workspace → UserRecord.default_workspace
- Identity.workspace → Identity.default_workspace
- JWT claim "workspace" → "default_workspace"
- IamResponse.resolved_workspace → resolved_default_workspace
- WebSocket auth-ok frame field → default_workspace
- Socket clients read default_workspace from auth-ok
- _user_record_to_dict wire key → default_workspace
- CLI help text and output updated throughout
- Test files updated for renamed fields
This commit is contained in:
Cyber MacGeddon 2026-06-25 16:25:11 +01:00
parent 16f8cfd972
commit a04bb9854c
19 changed files with 101 additions and 113 deletions

View file

@ -86,19 +86,19 @@ class TestVerifyJwtEddsa:
def test_valid_jwt_passes(self): def test_valid_jwt_passes(self):
priv, pub = make_keypair() priv, pub = make_keypair()
claims = { claims = {
"sub": "user-1", "workspace": "default", "sub": "user-1", "default_workspace": "default",
"iat": int(time.time()), "iat": int(time.time()),
"exp": int(time.time()) + 60, "exp": int(time.time()) + 60,
} }
token = sign_jwt(priv, claims) token = sign_jwt(priv, claims)
got = _verify_jwt_eddsa(token, pub) got = _verify_jwt_eddsa(token, pub)
assert got["sub"] == "user-1" assert got["sub"] == "user-1"
assert got["workspace"] == "default" assert got["default_workspace"] == "default"
def test_expired_jwt_rejected(self): def test_expired_jwt_rejected(self):
priv, pub = make_keypair() priv, pub = make_keypair()
claims = { claims = {
"sub": "user-1", "workspace": "default", "sub": "user-1", "default_workspace": "default",
"iat": int(time.time()) - 3600, "iat": int(time.time()) - 3600,
"exp": int(time.time()) - 1, "exp": int(time.time()) - 1,
} }
@ -110,7 +110,7 @@ class TestVerifyJwtEddsa:
priv_a, _ = make_keypair() priv_a, _ = make_keypair()
_, pub_b = make_keypair() _, pub_b = make_keypair()
claims = { claims = {
"sub": "user-1", "workspace": "default", "sub": "user-1", "default_workspace": "default",
"iat": int(time.time()), "iat": int(time.time()),
"exp": int(time.time()) + 60, "exp": int(time.time()) + 60,
} }
@ -130,7 +130,7 @@ class TestVerifyJwtEddsa:
# since we expect it to bail before verifying. # since we expect it to bail before verifying.
header = {"alg": "HS256", "typ": "JWT", "kid": "x"} header = {"alg": "HS256", "typ": "JWT", "kid": "x"}
payload = { payload = {
"sub": "user-1", "workspace": "default", "sub": "user-1", "default_workspace": "default",
"iat": int(time.time()), "exp": int(time.time()) + 60, "iat": int(time.time()), "exp": int(time.time()) + 60,
} }
h = _b64url(json.dumps(header, separators=(",", ":")).encode()) h = _b64url(json.dumps(header, separators=(",", ":")).encode())
@ -148,11 +148,11 @@ class TestIdentity:
def test_fields(self): def test_fields(self):
i = Identity( i = Identity(
handle="u", workspace="w", handle="u", default_workspace="w",
principal_id="u", source="api-key", principal_id="u", source="api-key",
) )
assert i.handle == "u" assert i.handle == "u"
assert i.workspace == "w" assert i.default_workspace == "w"
assert i.principal_id == "u" assert i.principal_id == "u"
assert i.source == "api-key" assert i.source == "api-key"
@ -208,7 +208,7 @@ class TestIamAuthDispatch:
async def test_valid_jwt_resolves_to_identity(self): async def test_valid_jwt_resolves_to_identity(self):
priv, pub = make_keypair() priv, pub = make_keypair()
claims = { claims = {
"sub": "user-1", "workspace": "default", "sub": "user-1", "default_workspace": "default",
"iat": int(time.time()), "iat": int(time.time()),
"exp": int(time.time()) + 60, "exp": int(time.time()) + 60,
} }
@ -221,7 +221,7 @@ class TestIamAuthDispatch:
make_request(f"Bearer {token}") make_request(f"Bearer {token}")
) )
assert ident.handle == "user-1" assert ident.handle == "user-1"
assert ident.workspace == "default" assert ident.default_workspace == "default"
assert ident.principal_id == "user-1" assert ident.principal_id == "user-1"
assert ident.source == "jwt" assert ident.source == "jwt"
@ -231,7 +231,7 @@ class TestIamAuthDispatch:
# must not validate — even ones that would otherwise pass. # must not validate — even ones that would otherwise pass.
priv, _ = make_keypair() priv, _ = make_keypair()
claims = { claims = {
"sub": "user-1", "workspace": "default", "sub": "user-1", "default_workspace": "default",
"iat": int(time.time()), "exp": int(time.time()) + 60, "iat": int(time.time()), "exp": int(time.time()) + 60,
} }
token = sign_jwt(priv, claims) token = sign_jwt(priv, claims)
@ -259,7 +259,7 @@ class TestIamAuthDispatch:
make_request("Bearer tg_testkey") make_request("Bearer tg_testkey")
) )
assert ident.handle == "user-xyz" assert ident.handle == "user-xyz"
assert ident.workspace == "default" assert ident.default_workspace == "default"
assert ident.principal_id == "user-xyz" assert ident.principal_id == "user-xyz"
assert ident.source == "api-key" assert ident.source == "api-key"
@ -338,9 +338,9 @@ class TestAuthorise:
decision for the regime's TTL (clamped above), and raises 403 decision for the regime's TTL (clamped above), and raises 403
on deny / 401 on regime error (fail closed).""" on deny / 401 on regime error (fail closed)."""
def _make_identity(self, handle="u-1", workspace="default"): def _make_identity(self, handle="u-1", default_workspace="default"):
return Identity( return Identity(
handle=handle, workspace=workspace, handle=handle, default_workspace=default_workspace,
principal_id=handle, source="api-key", principal_id=handle, source="api-key",
) )

View file

@ -25,11 +25,11 @@ from trustgraph.gateway.capabilities import (
class _Identity: class _Identity:
"""Stand-in for auth.Identity — under the IAM contract it has """Stand-in for auth.Identity — under the IAM contract it has
just ``handle``, ``workspace``, ``principal_id``, ``source``.""" just ``handle``, ``default_workspace``, ``principal_id``, ``source``."""
def __init__(self, handle="user-1", workspace="default"): def __init__(self, handle="user-1", default_workspace="default"):
self.handle = handle self.handle = handle
self.workspace = workspace self.default_workspace = default_workspace
self.principal_id = handle self.principal_id = handle
self.source = "api-key" self.source = "api-key"
@ -105,14 +105,14 @@ class TestEnforceWorkspace:
async def test_default_fills_from_identity(self): async def test_default_fills_from_identity(self):
data = {"operation": "x"} data = {"operation": "x"}
auth = _allow_auth() auth = _allow_auth()
await enforce_workspace(data, _Identity(workspace="default"), auth) await enforce_workspace(data, _Identity(default_workspace="default"), auth)
assert data["workspace"] == "default" assert data["workspace"] == "default"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_caller_supplied_workspace_kept(self): async def test_caller_supplied_workspace_kept(self):
data = {"workspace": "acme", "operation": "x"} data = {"workspace": "acme", "operation": "x"}
auth = _allow_auth() auth = _allow_auth()
await enforce_workspace(data, _Identity(workspace="default"), auth) await enforce_workspace(data, _Identity(default_workspace="default"), auth)
assert data["workspace"] == "acme" assert data["workspace"] == "acme"
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -28,7 +28,7 @@ TEST_CAP = "graph:write"
def _valid_identity(): def _valid_identity():
return Identity( return Identity(
handle="test-user", handle="test-user",
workspace="default", default_workspace="default",
principal_id="test-user", principal_id="test-user",
source="api-key", source="api-key",
) )

View file

@ -32,7 +32,7 @@ class TestAuthenticateAnonymous:
) )
assert resp.error is None assert resp.error is None
assert resp.resolved_user_id == "anon" assert resp.resolved_user_id == "anon"
assert resp.resolved_workspace == "ws" assert resp.resolved_default_workspace == "ws"
assert "admin" in list(resp.resolved_roles) assert "admin" in list(resp.resolved_roles)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -44,7 +44,7 @@ class TestAuthenticateAnonymous:
_make_request(operation="authenticate-anonymous") _make_request(operation="authenticate-anonymous")
) )
assert resp.resolved_user_id == "dev-user" assert resp.resolved_user_id == "dev-user"
assert resp.resolved_workspace == "dev-ws" assert resp.resolved_default_workspace == "dev-ws"
class TestResolveApiKey: class TestResolveApiKey:
@ -57,7 +57,7 @@ class TestResolveApiKey:
) )
assert resp.error is None assert resp.error is None
assert resp.resolved_user_id == "anonymous" assert resp.resolved_user_id == "anonymous"
assert resp.resolved_workspace == "default" assert resp.resolved_default_workspace == "default"
class TestAuthorise: class TestAuthorise:

View file

@ -94,7 +94,9 @@ class AsyncSocketClient:
if resp.get("type") == "auth-ok": if resp.get("type") == "auth-ok":
if not self._workspace_explicit: if not self._workspace_explicit:
self.workspace = resp.get("workspace", self.workspace) self.workspace = resp.get(
"default_workspace", self.workspace,
)
elif resp.get("type") == "auth-failed": elif resp.get("type") == "auth-failed":
await self._socket.close() await self._socket.close()
raise ProtocolException( raise ProtocolException(

View file

@ -168,7 +168,9 @@ class SocketClient:
if resp.get("type") == "auth-ok": if resp.get("type") == "auth-ok":
if self.workspace == "default": if self.workspace == "default":
self.workspace = resp.get("workspace", self.workspace) self.workspace = resp.get(
"default_workspace", self.workspace,
)
elif resp.get("type") == "auth-failed": elif resp.get("type") == "auth-failed":
await self._socket.close() await self._socket.close()
raise ProtocolException( raise ProtocolException(

View file

@ -65,31 +65,25 @@ class IamClient(RequestResponse):
async def authenticate_anonymous(self, timeout=IAM_TIMEOUT): async def authenticate_anonymous(self, timeout=IAM_TIMEOUT):
"""Request anonymous access from the IAM regime. """Request anonymous access from the IAM regime.
Returns ``(user_id, workspace, roles)`` if the regime permits Returns ``(user_id, default_workspace, roles)`` if the regime
anonymous access, or raises ``RuntimeError`` with error type permits anonymous access, or raises ``RuntimeError`` with
``auth-failed`` if it does not.""" error type ``auth-failed`` if it does not."""
resp = await self._request( resp = await self._request(
operation="authenticate-anonymous", operation="authenticate-anonymous",
timeout=timeout, timeout=timeout,
) )
return ( return (
resp.resolved_user_id, resp.resolved_user_id,
resp.resolved_workspace, resp.resolved_default_workspace,
list(resp.resolved_roles), list(resp.resolved_roles),
) )
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.
Returns ``(user_id, workspace, roles)`` or raises Returns ``(user_id, default_workspace, roles)`` or raises
``RuntimeError`` with error type ``auth-failed`` if the key is ``RuntimeError`` with error type ``auth-failed`` if the key is
unknown / expired / revoked. unknown / expired / revoked."""
Note: the ``roles`` value is a regime-internal hint and is
not used by the gateway directly under the IAM contract;
all authorisation decisions go through ``authorise()``.
Returned here only for backward compatibility with callers
that haven't migrated."""
resp = await self._request( resp = await self._request(
operation="resolve-api-key", operation="resolve-api-key",
api_key=api_key, api_key=api_key,
@ -97,7 +91,7 @@ class IamClient(RequestResponse):
) )
return ( return (
resp.resolved_user_id, resp.resolved_user_id,
resp.resolved_workspace, resp.resolved_default_workspace,
list(resp.resolved_roles), list(resp.resolved_roles),
) )

View file

@ -68,7 +68,7 @@ def _user_record_to_dict(r):
return None return None
return { return {
"id": r.id, "id": r.id,
"workspace": r.workspace, "default_workspace": r.default_workspace,
"username": r.username, "username": r.username,
"name": r.name, "name": r.name,
"email": r.email, "email": r.email,
@ -218,8 +218,8 @@ class IamResponseTranslator(MessageTranslator):
result["signing_key_public"] = obj.signing_key_public result["signing_key_public"] = obj.signing_key_public
if obj.resolved_user_id: if obj.resolved_user_id:
result["resolved_user_id"] = obj.resolved_user_id result["resolved_user_id"] = obj.resolved_user_id
if obj.resolved_workspace: if obj.resolved_default_workspace:
result["resolved_workspace"] = obj.resolved_workspace result["resolved_default_workspace"] = obj.resolved_default_workspace
if obj.resolved_roles: if obj.resolved_roles:
result["resolved_roles"] = list(obj.resolved_roles) result["resolved_roles"] = list(obj.resolved_roles)
if obj.temporary_password: if obj.temporary_password:

View file

@ -29,7 +29,7 @@ class UserInput:
@dataclass @dataclass
class UserRecord: class UserRecord:
id: str = "" id: str = ""
workspace: str = "" default_workspace: str = ""
username: str = "" username: str = ""
name: str = "" name: str = ""
email: str = "" email: str = ""
@ -160,7 +160,7 @@ class IamResponse:
# resolve-api-key # resolve-api-key
resolved_user_id: str = "" resolved_user_id: str = ""
resolved_workspace: str = "" resolved_default_workspace: str = ""
resolved_roles: list[str] = field(default_factory=list) resolved_roles: list[str] = field(default_factory=list)
# reset-password # reset-password

View file

@ -53,7 +53,7 @@ def main():
help="Auth token (default: $TRUSTGRAPH_TOKEN)", help="Auth token (default: $TRUSTGRAPH_TOKEN)",
) )
parser.add_argument( parser.add_argument(
"--username", required=True, help="Username (unique in workspace)", "--username", required=True, help="Username (globally unique)",
) )
parser.add_argument( parser.add_argument(
"--password", default=None, "--password", default=None,
@ -75,10 +75,7 @@ def main():
) )
parser.add_argument( parser.add_argument(
"-w", "--workspace", default=None, "-w", "--workspace", default=None,
help=( help="Default workspace for the new user",
"Target workspace (admin only; defaults to caller's "
"assigned workspace)"
),
) )
run_main(do_create_user, parser) run_main(do_create_user, parser)

View file

@ -51,8 +51,8 @@ def main():
parser.add_argument( parser.add_argument(
"-w", "--workspace", default=None, "-w", "--workspace", default=None,
help=( help=(
"Optional workspace to log in against. Defaults to " "Override the default workspace for this session's JWT. "
"the user's assigned workspace." "If omitted, uses the user's stored default workspace."
), ),
) )
run_main(do_login, parser) run_main(do_login, parser)

View file

@ -68,7 +68,7 @@ def do_update_user(args):
print(f"username : {rec.get('username', '')}") print(f"username : {rec.get('username', '')}")
print(f"name : {rec.get('name', '')}") print(f"name : {rec.get('name', '')}")
print(f"email : {rec.get('email', '')}") print(f"email : {rec.get('email', '')}")
print(f"workspace : {rec.get('workspace', '')}") print(f"default_ws: {rec.get('default_workspace', '')}")
print(f"roles : {', '.join(rec.get('roles', []))}") print(f"roles : {', '.join(rec.get('roles', []))}")
print(f"enabled : {'yes' if rec.get('enabled') else 'no'}") print(f"enabled : {'yes' if rec.get('enabled') else 'no'}")
print( print(
@ -114,7 +114,7 @@ def main():
"-w", "--workspace", default=None, "-w", "--workspace", default=None,
help=( help=(
"Optional workspace integrity check — when supplied, " "Optional workspace integrity check — when supplied, "
"iam-svc verifies the target user's home workspace " "iam-svc verifies the target user's default workspace "
"matches" "matches"
), ),
) )

View file

@ -57,16 +57,17 @@ class Identity:
# the OSS regime this is the user record's id; the gateway # the OSS regime this is the user record's id; the gateway
# treats it as a string with no semantic content. # treats it as a string with no semantic content.
handle: str handle: str
# The workspace this credential authenticates to. Used by the # The user's default workspace. Used by the gateway as the
# gateway as the default-fill-in for operations that omit a # default-fill-in for operations that omit a workspace. Not a
# workspace. Never used as policy input. # permission boundary — workspace access is controlled by the
workspace: str # IAM regime's authorise() decision, not by this field.
default_workspace: str
# Stable identifier for audit logs. In OSS this is the same # Stable identifier for audit logs. In OSS this is the same
# value as ``handle``; not assumed equal in the contract. # value as ``handle``; not assumed equal in the contract.
principal_id: str principal_id: str
# How the credential was presented. Non-policy; useful for # How the credential was presented. Non-policy; useful for
# logs / metrics only. # logs / metrics only.
source: str # "api-key" | "jwt" source: str # "api-key" | "jwt" | "anonymous"
def _auth_failure(): def _auth_failure():
@ -256,21 +257,22 @@ class IamAuth:
raise _auth_failure() raise _auth_failure()
sub = claims.get("sub", "") sub = claims.get("sub", "")
ws = claims.get("workspace", "") ws = claims.get("default_workspace", "")
if not sub or not ws: if not sub or not ws:
raise _auth_failure() raise _auth_failure()
# JWT carries no policy state under the IAM contract;
# any roles / claims field is ignored here.
return Identity( return Identity(
handle=sub, workspace=ws, principal_id=sub, source="jwt", handle=sub, default_workspace=ws,
principal_id=sub, source="jwt",
) )
async def _authenticate_anonymous(self): async def _authenticate_anonymous(self):
try: try:
async def _call(client): async def _call(client):
return await client.authenticate_anonymous() return await client.authenticate_anonymous()
user_id, workspace, _roles = await self._with_client(_call) user_id, default_workspace, _roles = await self._with_client(
_call,
)
except Exception as e: except Exception as e:
logger.debug( logger.debug(
f"Anonymous authentication rejected: " f"Anonymous authentication rejected: "
@ -278,11 +280,11 @@ class IamAuth:
) )
raise _auth_failure() raise _auth_failure()
if not user_id or not workspace: if not user_id or not default_workspace:
raise _auth_failure() raise _auth_failure()
return Identity( return Identity(
handle=user_id, workspace=workspace, handle=user_id, default_workspace=default_workspace,
principal_id=user_id, source="anonymous", principal_id=user_id, source="anonymous",
) )
@ -305,7 +307,9 @@ class IamAuth:
# ``roles`` is returned by the OSS regime as a hint # ``roles`` is returned by the OSS regime as a hint
# but is not consulted by the gateway; all policy # but is not consulted by the gateway; all policy
# decisions go through ``authorise``. # decisions go through ``authorise``.
user_id, workspace, _roles = await self._with_client(_call) user_id, default_workspace, _roles = await self._with_client(
_call,
)
except Exception as e: except Exception as e:
logger.debug( logger.debug(
f"API key resolution failed: " f"API key resolution failed: "
@ -313,11 +317,11 @@ class IamAuth:
) )
raise _auth_failure() raise _auth_failure()
if not user_id or not workspace: if not user_id or not default_workspace:
raise _auth_failure() raise _auth_failure()
identity = Identity( identity = Identity(
handle=user_id, workspace=workspace, handle=user_id, default_workspace=default_workspace,
principal_id=user_id, source="api-key", principal_id=user_id, source="api-key",
) )
self._key_cache[h] = (identity, now + API_KEY_CACHE_TTL) self._key_cache[h] = (identity, now + API_KEY_CACHE_TTL)

View file

@ -99,7 +99,7 @@ async def enforce_workspace(data, identity, auth, capability=None):
return data return data
requested = data.get("workspace", "") requested = data.get("workspace", "")
target = requested or identity.workspace target = requested or identity.default_workspace
data["workspace"] = target data["workspace"] = target
if target not in auth.known_workspaces: if target not in auth.known_workspaces:

View file

@ -80,7 +80,7 @@ class Mux:
self.identity = identity self.identity = identity
await self.ws.send_json({ await self.ws.send_json({
"type": "auth-ok", "type": "auth-ok",
"workspace": identity.workspace, "default_workspace": identity.default_workspace,
}) })
async def receive(self, msg): async def receive(self, msg):

View file

@ -92,7 +92,7 @@ class Operation:
# Returns a dict with the appropriate components for the # Returns a dict with the appropriate components for the
# resource level: {} for SYSTEM, {workspace} for WORKSPACE, # resource level: {} for SYSTEM, {workspace} for WORKSPACE,
# {workspace, flow} for FLOW. Default-fill-in of workspace # {workspace, flow} for FLOW. Default-fill-in of workspace
# from identity.workspace happens here when applicable. # from identity.default_workspace happens here when applicable.
extract_resource: Callable[[RequestContext], dict] extract_resource: Callable[[RequestContext], dict]
# Build the parameters dict — decision-relevant fields the # Build the parameters dict — decision-relevant fields the
@ -141,7 +141,7 @@ def _workspace_from_body(ctx: RequestContext) -> dict:
workspace field, defaulting to the caller's bound workspace.""" workspace field, defaulting to the caller's bound workspace."""
ws = (ctx.body.get("workspace") if isinstance(ctx.body, dict) else "") ws = (ctx.body.get("workspace") if isinstance(ctx.body, dict) else "")
if not ws and ctx.identity is not None: if not ws and ctx.identity is not None:
ws = ctx.identity.workspace ws = ctx.identity.default_workspace
return {"workspace": ws} return {"workspace": ws}
@ -188,7 +188,7 @@ def _workspace_param_only(ctx: RequestContext) -> dict:
or body.get("workspace") or body.get("workspace")
) )
if not ws and ctx.identity is not None: if not ws and ctx.identity is not None:
ws = ctx.identity.workspace ws = ctx.identity.default_workspace
return {"workspace": ws or ""} return {"workspace": ws or ""}

View file

@ -28,14 +28,14 @@ class NoAuthHandler:
def _default_identity_response(self): def _default_identity_response(self):
return IamResponse( return IamResponse(
resolved_user_id=self.default_user_id, resolved_user_id=self.default_user_id,
resolved_workspace=self.default_workspace, resolved_default_workspace=self.default_workspace,
resolved_roles=["admin"], resolved_roles=["admin"],
) )
def _default_user_record(self): def _default_user_record(self):
return UserRecord( return UserRecord(
id=self.default_user_id, id=self.default_user_id,
workspace=self.default_workspace, default_workspace=self.default_workspace,
username=self.default_user_id, username=self.default_user_id,
name="Anonymous User", name="Anonymous User",
roles=["admin"], roles=["admin"],

View file

@ -383,7 +383,7 @@ class IamService:
) = row ) = row
return UserRecord( return UserRecord(
id=id or "", id=id or "",
workspace=workspace or "", default_workspace=workspace or "",
username=username or "", username=username or "",
name=name or "", name=name or "",
email=email or "", email=email or "",
@ -596,14 +596,8 @@ class IamService:
if not v.password: if not v.password:
return _err("auth-failed", "password required") return _err("auth-failed", "password required")
# Login accepts an optional workspace parameter. If omitted
# we use the default workspace (OSS single-workspace
# assumption). Multi-workspace enterprise editions swap in a
# resolver that looks across the caller's permitted set.
workspace = v.workspace or DEFAULT_WORKSPACE
user_id = await self.table_store.get_user_id_by_username( user_id = await self.table_store.get_user_id_by_username(
workspace, v.username, v.username,
) )
if not user_id: if not user_id:
return _err("auth-failed", "no such user") return _err("auth-failed", "no such user")
@ -624,7 +618,10 @@ class IamService:
): ):
return _err("auth-failed", "bad credentials") return _err("auth-failed", "bad credentials")
ws_row = await self.table_store.get_workspace(ws) # JWT workspace: login request override, or the user's default.
jwt_workspace = v.workspace or ws
ws_row = await self.table_store.get_workspace(jwt_workspace)
if ws_row is None or not ws_row[2]: if ws_row is None or not ws_row[2]:
return _err("auth-failed", "workspace disabled") return _err("auth-failed", "workspace disabled")
@ -632,14 +629,10 @@ class IamService:
now_ts = int(_now_dt().timestamp()) now_ts = int(_now_dt().timestamp())
exp_ts = now_ts + JWT_TTL_SECONDS exp_ts = now_ts + JWT_TTL_SECONDS
# Per the IAM contract the gateway never reads policy state
# from the credential — roles stay server-side, reachable
# only via authorise(). JWT carries identity + workspace
# binding only.
claims = { claims = {
"iss": JWT_ISSUER, "iss": JWT_ISSUER,
"sub": id, "sub": id,
"workspace": ws, "default_workspace": jwt_workspace,
"iat": now_ts, "iat": now_ts,
"exp": exp_ts, "exp": exp_ts,
} }
@ -878,20 +871,15 @@ class IamService:
# 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 — keyed on (workspace, username), # Remove global username lookup.
# 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(username)
record_workspace, username,
)
# Remove user record. # Remove user record.
await self.table_store.delete_user(v.user_id) await self.table_store.delete_user(v.user_id)
@ -1110,13 +1098,15 @@ class IamService:
return _err("auth-failed", "owning user disabled") return _err("auth-failed", "owning user disabled")
# Workspace-disabled check. # Workspace-disabled check.
ws_row = await self.table_store.get_workspace(user.workspace) ws_row = await self.table_store.get_workspace(
user.default_workspace,
)
if ws_row is None or not ws_row[2]: if ws_row is None or not ws_row[2]:
return _err("auth-failed", "owning workspace disabled") return _err("auth-failed", "owning workspace disabled")
return IamResponse( return IamResponse(
resolved_user_id=user.id, resolved_user_id=user.id,
resolved_workspace=user.workspace, resolved_default_workspace=user.default_workspace,
resolved_roles=list(user.roles), resolved_roles=list(user.roles),
) )
@ -1143,9 +1133,9 @@ class IamService:
if ws is None or not ws[2]: if ws is None or not ws[2]:
return _err("not-found", "workspace not found or disabled") return _err("not-found", "workspace not found or disabled")
# Uniqueness on username within workspace. # Global username uniqueness.
existing = await self.table_store.get_user_id_by_username( existing = await self.table_store.get_user_id_by_username(
v.workspace, v.user.username, v.user.username,
) )
if existing: if existing:
return _err("duplicate", "username already exists") return _err("duplicate", "username already exists")
@ -1317,8 +1307,9 @@ class IamService:
return False, AUTHZ_CACHE_TTL_SECONDS return False, AUTHZ_CACHE_TTL_SECONDS
# user_row layout: # user_row layout:
# 0:id 1:workspace 2:username 3:name 4:email 5:password_hash # 0:id 1:default_workspace 2:username 3:name 4:email
# 6:roles 7:enabled 8:must_change_password 9:created # 5:password_hash 6:roles 7:enabled 8:must_change_password
# 9:created
if not user_row[7]: # disabled if not user_row[7]: # disabled
return False, AUTHZ_CACHE_TTL_SECONDS return False, AUTHZ_CACHE_TTL_SECONDS

View file

@ -94,10 +94,8 @@ class IamTableStore:
self.cassandra.execute(""" self.cassandra.execute("""
CREATE TABLE IF NOT EXISTS iam_users_by_username ( CREATE TABLE IF NOT EXISTS iam_users_by_username (
workspace text, username text PRIMARY KEY,
username text, user_id text
user_id text,
PRIMARY KEY ((workspace), username)
); );
""") """)
@ -175,16 +173,16 @@ class IamTableStore:
""") """)
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 (username, user_id)
VALUES (?, ?, ?) VALUES (?, ?)
""") """)
self.get_user_id_by_username_stmt = c.prepare(""" self.get_user_id_by_username_stmt = c.prepare("""
SELECT user_id FROM iam_users_by_username SELECT user_id FROM iam_users_by_username
WHERE workspace = ? AND username = ? WHERE username = ?
""") """)
self.delete_username_lookup_stmt = c.prepare(""" self.delete_username_lookup_stmt = c.prepare("""
DELETE FROM iam_users_by_username DELETE FROM iam_users_by_username
WHERE workspace = ? AND username = ? WHERE username = ?
""") """)
self.delete_user_stmt = c.prepare(""" self.delete_user_stmt = c.prepare("""
DELETE FROM iam_users WHERE id = ? DELETE FROM iam_users WHERE id = ?
@ -289,7 +287,7 @@ class IamTableStore:
) )
await async_execute( await async_execute(
self.cassandra, self.put_username_lookup_stmt, self.cassandra, self.put_username_lookup_stmt,
(workspace, username, id), (username, id),
) )
async def get_user(self, id): async def get_user(self, id):
@ -298,10 +296,10 @@ class IamTableStore:
) )
return rows[0] if rows else None return rows[0] if rows else None
async def get_user_id_by_username(self, workspace, username): async def get_user_id_by_username(self, username):
rows = await async_execute( rows = await async_execute(
self.cassandra, self.get_user_id_by_username_stmt, self.cassandra, self.get_user_id_by_username_stmt,
(workspace, username), (username,),
) )
return rows[0][0] if rows else None return rows[0][0] if rows else None
@ -324,10 +322,10 @@ class IamTableStore:
self.cassandra, self.delete_user_stmt, (id,), self.cassandra, self.delete_user_stmt, (id,),
) )
async def delete_username_lookup(self, workspace, username): async def delete_username_lookup(self, username):
await async_execute( await async_execute(
self.cassandra, self.delete_username_lookup_stmt, self.cassandra, self.delete_username_lookup_stmt,
(workspace, username), (username,),
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------