mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-06-26 15:09:38 +02:00
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:
parent
16f8cfd972
commit
0a828379be
19 changed files with 101 additions and 113 deletions
|
|
@ -57,16 +57,17 @@ class Identity:
|
|||
# the OSS regime this is the user record's id; the gateway
|
||||
# treats it as a string with no semantic content.
|
||||
handle: str
|
||||
# The workspace this credential authenticates to. Used by the
|
||||
# gateway as the default-fill-in for operations that omit a
|
||||
# workspace. Never used as policy input.
|
||||
workspace: str
|
||||
# The user's default workspace. Used by the gateway as the
|
||||
# default-fill-in for operations that omit a workspace. Not a
|
||||
# permission boundary — workspace access is controlled by the
|
||||
# IAM regime's authorise() decision, not by this field.
|
||||
default_workspace: str
|
||||
# Stable identifier for audit logs. In OSS this is the same
|
||||
# value as ``handle``; not assumed equal in the contract.
|
||||
principal_id: str
|
||||
# How the credential was presented. Non-policy; useful for
|
||||
# logs / metrics only.
|
||||
source: str # "api-key" | "jwt"
|
||||
source: str # "api-key" | "jwt" | "anonymous"
|
||||
|
||||
|
||||
def _auth_failure():
|
||||
|
|
@ -256,21 +257,22 @@ class IamAuth:
|
|||
raise _auth_failure()
|
||||
|
||||
sub = claims.get("sub", "")
|
||||
ws = claims.get("workspace", "")
|
||||
ws = claims.get("default_workspace", "")
|
||||
if not sub or not ws:
|
||||
raise _auth_failure()
|
||||
|
||||
# JWT carries no policy state under the IAM contract;
|
||||
# any roles / claims field is ignored here.
|
||||
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):
|
||||
try:
|
||||
async def _call(client):
|
||||
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:
|
||||
logger.debug(
|
||||
f"Anonymous authentication rejected: "
|
||||
|
|
@ -278,11 +280,11 @@ class IamAuth:
|
|||
)
|
||||
raise _auth_failure()
|
||||
|
||||
if not user_id or not workspace:
|
||||
if not user_id or not default_workspace:
|
||||
raise _auth_failure()
|
||||
|
||||
return Identity(
|
||||
handle=user_id, workspace=workspace,
|
||||
handle=user_id, default_workspace=default_workspace,
|
||||
principal_id=user_id, source="anonymous",
|
||||
)
|
||||
|
||||
|
|
@ -305,7 +307,9 @@ class IamAuth:
|
|||
# ``roles`` is returned by the OSS regime as a hint
|
||||
# but is not consulted by the gateway; all policy
|
||||
# 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:
|
||||
logger.debug(
|
||||
f"API key resolution failed: "
|
||||
|
|
@ -313,11 +317,11 @@ class IamAuth:
|
|||
)
|
||||
raise _auth_failure()
|
||||
|
||||
if not user_id or not workspace:
|
||||
if not user_id or not default_workspace:
|
||||
raise _auth_failure()
|
||||
|
||||
identity = Identity(
|
||||
handle=user_id, workspace=workspace,
|
||||
handle=user_id, default_workspace=default_workspace,
|
||||
principal_id=user_id, source="api-key",
|
||||
)
|
||||
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
|
||||
|
||||
requested = data.get("workspace", "")
|
||||
target = requested or identity.workspace
|
||||
target = requested or identity.default_workspace
|
||||
data["workspace"] = target
|
||||
|
||||
if target not in auth.known_workspaces:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class Mux:
|
|||
self.identity = identity
|
||||
await self.ws.send_json({
|
||||
"type": "auth-ok",
|
||||
"workspace": identity.workspace,
|
||||
"default_workspace": identity.default_workspace,
|
||||
})
|
||||
|
||||
async def receive(self, msg):
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class Operation:
|
|||
# Returns a dict with the appropriate components for the
|
||||
# resource level: {} for SYSTEM, {workspace} for 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]
|
||||
|
||||
# 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."""
|
||||
ws = (ctx.body.get("workspace") if isinstance(ctx.body, dict) else "")
|
||||
if not ws and ctx.identity is not None:
|
||||
ws = ctx.identity.workspace
|
||||
ws = ctx.identity.default_workspace
|
||||
return {"workspace": ws}
|
||||
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ def _workspace_param_only(ctx: RequestContext) -> dict:
|
|||
or body.get("workspace")
|
||||
)
|
||||
if not ws and ctx.identity is not None:
|
||||
ws = ctx.identity.workspace
|
||||
ws = ctx.identity.default_workspace
|
||||
return {"workspace": ws or ""}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ class NoAuthHandler:
|
|||
def _default_identity_response(self):
|
||||
return IamResponse(
|
||||
resolved_user_id=self.default_user_id,
|
||||
resolved_workspace=self.default_workspace,
|
||||
resolved_default_workspace=self.default_workspace,
|
||||
resolved_roles=["admin"],
|
||||
)
|
||||
|
||||
def _default_user_record(self):
|
||||
return UserRecord(
|
||||
id=self.default_user_id,
|
||||
workspace=self.default_workspace,
|
||||
default_workspace=self.default_workspace,
|
||||
username=self.default_user_id,
|
||||
name="Anonymous User",
|
||||
roles=["admin"],
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ class IamService:
|
|||
) = row
|
||||
return UserRecord(
|
||||
id=id or "",
|
||||
workspace=workspace or "",
|
||||
default_workspace=workspace or "",
|
||||
username=username or "",
|
||||
name=name or "",
|
||||
email=email or "",
|
||||
|
|
@ -596,14 +596,8 @@ class IamService:
|
|||
if not v.password:
|
||||
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(
|
||||
workspace, v.username,
|
||||
v.username,
|
||||
)
|
||||
if not user_id:
|
||||
return _err("auth-failed", "no such user")
|
||||
|
|
@ -624,7 +618,10 @@ class IamService:
|
|||
):
|
||||
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]:
|
||||
return _err("auth-failed", "workspace disabled")
|
||||
|
||||
|
|
@ -632,14 +629,10 @@ class IamService:
|
|||
|
||||
now_ts = int(_now_dt().timestamp())
|
||||
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 = {
|
||||
"iss": JWT_ISSUER,
|
||||
"sub": id,
|
||||
"workspace": ws,
|
||||
"default_workspace": jwt_workspace,
|
||||
"iat": now_ts,
|
||||
"exp": exp_ts,
|
||||
}
|
||||
|
|
@ -878,20 +871,15 @@ class IamService:
|
|||
|
||||
# user_row indices match get_user columns. Username is [2].
|
||||
username = user_row[2]
|
||||
record_workspace = user_row[1]
|
||||
|
||||
# Revoke all API keys.
|
||||
key_rows = await self.table_store.list_api_keys_by_user(v.user_id)
|
||||
for kr in key_rows:
|
||||
await self.table_store.delete_api_key(kr[0])
|
||||
|
||||
# Remove username lookup — keyed on (workspace, username),
|
||||
# so use the resolved workspace from the user record rather
|
||||
# than relying on the caller-supplied filter.
|
||||
# Remove global username lookup.
|
||||
if username:
|
||||
await self.table_store.delete_username_lookup(
|
||||
record_workspace, username,
|
||||
)
|
||||
await self.table_store.delete_username_lookup(username)
|
||||
|
||||
# Remove user record.
|
||||
await self.table_store.delete_user(v.user_id)
|
||||
|
|
@ -1110,13 +1098,15 @@ class IamService:
|
|||
return _err("auth-failed", "owning user disabled")
|
||||
|
||||
# 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]:
|
||||
return _err("auth-failed", "owning workspace disabled")
|
||||
|
||||
return IamResponse(
|
||||
resolved_user_id=user.id,
|
||||
resolved_workspace=user.workspace,
|
||||
resolved_default_workspace=user.default_workspace,
|
||||
resolved_roles=list(user.roles),
|
||||
)
|
||||
|
||||
|
|
@ -1143,9 +1133,9 @@ class IamService:
|
|||
if ws is None or not ws[2]:
|
||||
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(
|
||||
v.workspace, v.user.username,
|
||||
v.user.username,
|
||||
)
|
||||
if existing:
|
||||
return _err("duplicate", "username already exists")
|
||||
|
|
@ -1317,8 +1307,9 @@ class IamService:
|
|||
return False, AUTHZ_CACHE_TTL_SECONDS
|
||||
|
||||
# user_row layout:
|
||||
# 0:id 1:workspace 2:username 3:name 4:email 5:password_hash
|
||||
# 6:roles 7:enabled 8:must_change_password 9:created
|
||||
# 0:id 1:default_workspace 2:username 3:name 4:email
|
||||
# 5:password_hash 6:roles 7:enabled 8:must_change_password
|
||||
# 9:created
|
||||
if not user_row[7]: # disabled
|
||||
return False, AUTHZ_CACHE_TTL_SECONDS
|
||||
|
||||
|
|
|
|||
|
|
@ -94,10 +94,8 @@ class IamTableStore:
|
|||
|
||||
self.cassandra.execute("""
|
||||
CREATE TABLE IF NOT EXISTS iam_users_by_username (
|
||||
workspace text,
|
||||
username text,
|
||||
user_id text,
|
||||
PRIMARY KEY ((workspace), username)
|
||||
username text PRIMARY KEY,
|
||||
user_id text
|
||||
);
|
||||
""")
|
||||
|
||||
|
|
@ -175,16 +173,16 @@ class IamTableStore:
|
|||
""")
|
||||
|
||||
self.put_username_lookup_stmt = c.prepare("""
|
||||
INSERT INTO iam_users_by_username (workspace, username, user_id)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO iam_users_by_username (username, user_id)
|
||||
VALUES (?, ?)
|
||||
""")
|
||||
self.get_user_id_by_username_stmt = c.prepare("""
|
||||
SELECT user_id FROM iam_users_by_username
|
||||
WHERE workspace = ? AND username = ?
|
||||
WHERE username = ?
|
||||
""")
|
||||
self.delete_username_lookup_stmt = c.prepare("""
|
||||
DELETE FROM iam_users_by_username
|
||||
WHERE workspace = ? AND username = ?
|
||||
WHERE username = ?
|
||||
""")
|
||||
self.delete_user_stmt = c.prepare("""
|
||||
DELETE FROM iam_users WHERE id = ?
|
||||
|
|
@ -289,7 +287,7 @@ class IamTableStore:
|
|||
)
|
||||
await async_execute(
|
||||
self.cassandra, self.put_username_lookup_stmt,
|
||||
(workspace, username, id),
|
||||
(username, id),
|
||||
)
|
||||
|
||||
async def get_user(self, id):
|
||||
|
|
@ -298,10 +296,10 @@ class IamTableStore:
|
|||
)
|
||||
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(
|
||||
self.cassandra, self.get_user_id_by_username_stmt,
|
||||
(workspace, username),
|
||||
(username,),
|
||||
)
|
||||
return rows[0][0] if rows else None
|
||||
|
||||
|
|
@ -324,10 +322,10 @@ class IamTableStore:
|
|||
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(
|
||||
self.cassandra, self.delete_username_lookup_stmt,
|
||||
(workspace, username),
|
||||
(username,),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue