feat: global usernames and rename workspace to default_workspace (#1001)

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:
cybermaggedon 2026-06-25 16:34:31 +01:00 committed by GitHub
parent 16f8cfd972
commit 0a828379be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 101 additions and 113 deletions

View file

@ -94,7 +94,9 @@ class AsyncSocketClient:
if resp.get("type") == "auth-ok":
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":
await self._socket.close()
raise ProtocolException(

View file

@ -168,7 +168,9 @@ class SocketClient:
if resp.get("type") == "auth-ok":
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":
await self._socket.close()
raise ProtocolException(

View file

@ -65,31 +65,25 @@ class IamClient(RequestResponse):
async def authenticate_anonymous(self, timeout=IAM_TIMEOUT):
"""Request anonymous access from the IAM regime.
Returns ``(user_id, workspace, roles)`` if the regime permits
anonymous access, or raises ``RuntimeError`` with error type
``auth-failed`` if it does not."""
Returns ``(user_id, default_workspace, roles)`` if the regime
permits anonymous access, or raises ``RuntimeError`` with
error type ``auth-failed`` if it does not."""
resp = await self._request(
operation="authenticate-anonymous",
timeout=timeout,
)
return (
resp.resolved_user_id,
resp.resolved_workspace,
resp.resolved_default_workspace,
list(resp.resolved_roles),
)
async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT):
"""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
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."""
unknown / expired / revoked."""
resp = await self._request(
operation="resolve-api-key",
api_key=api_key,
@ -97,7 +91,7 @@ class IamClient(RequestResponse):
)
return (
resp.resolved_user_id,
resp.resolved_workspace,
resp.resolved_default_workspace,
list(resp.resolved_roles),
)

View file

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

View file

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