mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-06 03:42:11 +02:00
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:
parent
16f8cfd972
commit
a04bb9854c
19 changed files with 101 additions and 113 deletions
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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 ""}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue