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

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

View file

@ -25,11 +25,11 @@ from trustgraph.gateway.capabilities import (
class _Identity:
"""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.workspace = workspace
self.default_workspace = default_workspace
self.principal_id = handle
self.source = "api-key"
@ -105,14 +105,14 @@ class TestEnforceWorkspace:
async def test_default_fills_from_identity(self):
data = {"operation": "x"}
auth = _allow_auth()
await enforce_workspace(data, _Identity(workspace="default"), auth)
await enforce_workspace(data, _Identity(default_workspace="default"), auth)
assert data["workspace"] == "default"
@pytest.mark.asyncio
async def test_caller_supplied_workspace_kept(self):
data = {"workspace": "acme", "operation": "x"}
auth = _allow_auth()
await enforce_workspace(data, _Identity(workspace="default"), auth)
await enforce_workspace(data, _Identity(default_workspace="default"), auth)
assert data["workspace"] == "acme"
@pytest.mark.asyncio

View file

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

View file

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