diff --git a/trustgraph-base/trustgraph/base/iam_client.py b/trustgraph-base/trustgraph/base/iam_client.py index da016eb2..b36dd333 100644 --- a/trustgraph-base/trustgraph/base/iam_client.py +++ b/trustgraph-base/trustgraph/base/iam_client.py @@ -135,6 +135,118 @@ class IamClient(RequestResponse): ) return resp.signing_key_public + async def change_password(self, user_id, current_password, + new_password, timeout=IAM_TIMEOUT): + await self._request( + operation="change-password", + user_id=user_id, + password=current_password, + new_password=new_password, + timeout=timeout, + ) + + async def reset_password(self, workspace, user_id, actor="", + timeout=IAM_TIMEOUT): + """Admin-driven password reset. Returns the plaintext + temporary password (returned once).""" + resp = await self._request( + operation="reset-password", + workspace=workspace, + actor=actor, + user_id=user_id, + timeout=timeout, + ) + return resp.temporary_password + + async def get_user(self, workspace, user_id, actor="", + timeout=IAM_TIMEOUT): + resp = await self._request( + operation="get-user", + workspace=workspace, + actor=actor, + user_id=user_id, + timeout=timeout, + ) + return resp.user + + async def update_user(self, workspace, user_id, user, actor="", + timeout=IAM_TIMEOUT): + resp = await self._request( + operation="update-user", + workspace=workspace, + actor=actor, + user_id=user_id, + user=user, + timeout=timeout, + ) + return resp.user + + async def disable_user(self, workspace, user_id, actor="", + timeout=IAM_TIMEOUT): + await self._request( + operation="disable-user", + workspace=workspace, + actor=actor, + user_id=user_id, + timeout=timeout, + ) + + async def create_workspace(self, workspace_record, actor="", + timeout=IAM_TIMEOUT): + resp = await self._request( + operation="create-workspace", + actor=actor, + workspace_record=workspace_record, + timeout=timeout, + ) + return resp.workspace + + async def list_workspaces(self, actor="", timeout=IAM_TIMEOUT): + resp = await self._request( + operation="list-workspaces", + actor=actor, + timeout=timeout, + ) + return list(resp.workspaces) + + async def get_workspace(self, workspace_id, actor="", + timeout=IAM_TIMEOUT): + from ..schema import WorkspaceInput + resp = await self._request( + operation="get-workspace", + actor=actor, + workspace_record=WorkspaceInput(id=workspace_id), + timeout=timeout, + ) + return resp.workspace + + async def update_workspace(self, workspace_record, actor="", + timeout=IAM_TIMEOUT): + resp = await self._request( + operation="update-workspace", + actor=actor, + workspace_record=workspace_record, + timeout=timeout, + ) + return resp.workspace + + async def disable_workspace(self, workspace_id, actor="", + timeout=IAM_TIMEOUT): + from ..schema import WorkspaceInput + await self._request( + operation="disable-workspace", + actor=actor, + workspace_record=WorkspaceInput(id=workspace_id), + timeout=timeout, + ) + + async def rotate_signing_key(self, actor="", timeout=IAM_TIMEOUT): + await self._request( + operation="rotate-signing-key", + actor=actor, + timeout=timeout, + ) + class IamClientSpec(RequestResponseSpec): def __init__(self, request_name, response_name): diff --git a/trustgraph-flow/trustgraph/iam/service/iam.py b/trustgraph-flow/trustgraph/iam/service/iam.py index b2d561c8..2fde4a28 100644 --- a/trustgraph-flow/trustgraph/iam/service/iam.py +++ b/trustgraph-flow/trustgraph/iam/service/iam.py @@ -208,6 +208,28 @@ class IamService: return await self.handle_login(v) if op == "get-signing-key-public": return await self.handle_get_signing_key_public(v) + if op == "change-password": + return await self.handle_change_password(v) + if op == "reset-password": + return await self.handle_reset_password(v) + if op == "get-user": + return await self.handle_get_user(v) + if op == "update-user": + return await self.handle_update_user(v) + if op == "disable-user": + return await self.handle_disable_user(v) + if op == "create-workspace": + return await self.handle_create_workspace(v) + if op == "list-workspaces": + return await self.handle_list_workspaces(v) + if op == "get-workspace": + return await self.handle_get_workspace(v) + if op == "update-workspace": + return await self.handle_update_workspace(v) + if op == "disable-workspace": + return await self.handle_disable_workspace(v) + if op == "rotate-signing-key": + return await self.handle_rotate_signing_key(v) return _err( "invalid-argument", @@ -453,6 +475,348 @@ class IamService: _, _, public_pem = await self._get_active_signing_key() return IamResponse(signing_key_public=public_pem) + # ------------------------------------------------------------------ + # Record-conversion helper for workspaces + # ------------------------------------------------------------------ + + def _row_to_workspace_record(self, row): + id, name, enabled, created = row + return WorkspaceRecord( + id=id or "", + name=name or "", + enabled=bool(enabled), + created=_iso(created), + ) + + async def _user_in_workspace(self, user_id, workspace): + """Return (user_row, error_response_or_None). Loads the user + record, verifies it exists, is enabled, and belongs to + ``workspace``. The workspace scope check rejects cross- + workspace admin attempts.""" + user_row = await self.table_store.get_user(user_id) + if user_row is None: + return None, _err("not-found", "user not found") + if user_row[1] != workspace: + return None, _err( + "operation-not-permitted", + "user is in a different workspace", + ) + return user_row, None + + # ------------------------------------------------------------------ + # change-password + # ------------------------------------------------------------------ + + async def handle_change_password(self, v): + if not v.user_id: + return _err("invalid-argument", "user_id required") + if not v.password: + return _err("invalid-argument", "password (current) required") + if not v.new_password: + return _err("invalid-argument", "new_password required") + + user_row = await self.table_store.get_user(v.user_id) + if user_row is None: + return _err("auth-failed", "no such user") + + _id, _ws, _un, _name, _email, password_hash, _r, enabled, _mcp, _c = ( + user_row + ) + if not enabled: + return _err("auth-failed", "user disabled") + if not password_hash or not _verify_password( + v.password, password_hash, + ): + return _err("auth-failed", "bad credentials") + + await self.table_store.update_user_password( + id=v.user_id, + password_hash=_hash_password(v.new_password), + must_change_password=False, + ) + return IamResponse() + + # ------------------------------------------------------------------ + # reset-password + # ------------------------------------------------------------------ + + async def handle_reset_password(self, v): + if not v.workspace: + return _err( + "invalid-argument", + "workspace required for reset-password", + ) + if not v.user_id: + return _err("invalid-argument", "user_id required") + + _, err = await self._user_in_workspace(v.user_id, v.workspace) + if err is not None: + return err + + temporary = secrets.token_urlsafe(12) + await self.table_store.update_user_password( + id=v.user_id, + password_hash=_hash_password(temporary), + must_change_password=True, + ) + return IamResponse(temporary_password=temporary) + + # ------------------------------------------------------------------ + # get-user / update-user / disable-user + # ------------------------------------------------------------------ + + async def handle_get_user(self, v): + if not v.workspace: + return _err("invalid-argument", "workspace required") + if not v.user_id: + return _err("invalid-argument", "user_id required") + + user_row, err = await self._user_in_workspace( + v.user_id, v.workspace, + ) + if err is not None: + return err + return IamResponse(user=self._row_to_user_record(user_row)) + + async def handle_update_user(self, v): + """Update user profile fields: name, email, roles, enabled, + must_change_password. Username is immutable — change it by + creating a new user and disabling the old one. Password + changes go through change-password / reset-password.""" + if not v.workspace: + return _err("invalid-argument", "workspace required") + if not v.user_id: + return _err("invalid-argument", "user_id required") + if v.user is None: + return _err("invalid-argument", "user field required") + if v.user.password: + return _err( + "invalid-argument", + "password cannot be changed via update-user; " + "use change-password or reset-password", + ) + if v.user.username and v.user.username != "": + # Compare to existing. Username-change not allowed. + existing, err = await self._user_in_workspace( + v.user_id, v.workspace, + ) + if err is not None: + return err + if v.user.username != existing[2]: + return _err( + "invalid-argument", + "username is immutable; create a new user " + "instead", + ) + else: + existing, err = await self._user_in_workspace( + v.user_id, v.workspace, + ) + if err is not None: + return err + + # Carry forward fields the caller didn't provide. + ( + _id, _ws, _username, cur_name, cur_email, _pw, + cur_roles, cur_enabled, cur_mcp, _created, + ) = existing + + new_name = v.user.name if v.user.name else cur_name + new_email = v.user.email if v.user.email else cur_email + new_roles = list(v.user.roles) if v.user.roles else list( + cur_roles or [], + ) + new_enabled = v.user.enabled if v.user.enabled is not None else ( + cur_enabled + ) + new_mcp = ( + v.user.must_change_password + if v.user.must_change_password is not None + else cur_mcp + ) + + await self.table_store.update_user_profile( + id=v.user_id, + name=new_name, + email=new_email, + roles=new_roles, + enabled=new_enabled, + must_change_password=new_mcp, + ) + + updated = await self.table_store.get_user(v.user_id) + return IamResponse(user=self._row_to_user_record(updated)) + + async def handle_disable_user(self, v): + """Soft-delete: set enabled=false and revoke every API key + belonging to the user.""" + if not v.workspace: + return _err("invalid-argument", "workspace required") + if not v.user_id: + return _err("invalid-argument", "user_id required") + + _, err = await self._user_in_workspace(v.user_id, v.workspace) + if err is not None: + return err + + await self.table_store.update_user_enabled( + id=v.user_id, enabled=False, + ) + + # Revoke all their 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]) + + return IamResponse() + + # ------------------------------------------------------------------ + # Workspace CRUD + # ------------------------------------------------------------------ + + async def handle_create_workspace(self, v): + if v.workspace_record is None or not v.workspace_record.id: + return _err( + "invalid-argument", + "workspace_record.id required for create-workspace", + ) + if v.workspace_record.id.startswith("_"): + return _err( + "invalid-argument", + "workspace ids beginning with '_' are reserved", + ) + + existing = await self.table_store.get_workspace( + v.workspace_record.id, + ) + if existing is not None: + return _err("duplicate", "workspace already exists") + + now = _now_dt() + await self.table_store.put_workspace( + id=v.workspace_record.id, + name=v.workspace_record.name or v.workspace_record.id, + enabled=v.workspace_record.enabled, + created=now, + ) + row = await self.table_store.get_workspace(v.workspace_record.id) + return IamResponse(workspace=self._row_to_workspace_record(row)) + + async def handle_list_workspaces(self, v): + rows = await self.table_store.list_workspaces() + return IamResponse( + workspaces=[ + self._row_to_workspace_record(r) for r in rows + ], + ) + + async def handle_get_workspace(self, v): + if v.workspace_record is None or not v.workspace_record.id: + return _err("invalid-argument", "workspace_record.id required") + row = await self.table_store.get_workspace(v.workspace_record.id) + if row is None: + return _err("not-found", "workspace not found") + return IamResponse(workspace=self._row_to_workspace_record(row)) + + async def handle_update_workspace(self, v): + """Update workspace name / enabled. The id is immutable.""" + if v.workspace_record is None or not v.workspace_record.id: + return _err("invalid-argument", "workspace_record.id required") + row = await self.table_store.get_workspace(v.workspace_record.id) + if row is None: + return _err("not-found", "workspace not found") + + _, cur_name, cur_enabled, _created = row + new_name = ( + v.workspace_record.name + if v.workspace_record.name else cur_name + ) + new_enabled = ( + v.workspace_record.enabled + if v.workspace_record.enabled is not None + else cur_enabled + ) + + await self.table_store.update_workspace( + id=v.workspace_record.id, + name=new_name, + enabled=new_enabled, + ) + updated = await self.table_store.get_workspace( + v.workspace_record.id, + ) + return IamResponse( + workspace=self._row_to_workspace_record(updated), + ) + + async def handle_disable_workspace(self, v): + """Set enabled=false, disable every user in the workspace, + revoke every API key belonging to those users.""" + if v.workspace_record is None or not v.workspace_record.id: + return _err("invalid-argument", "workspace_record.id required") + + row = await self.table_store.get_workspace(v.workspace_record.id) + if row is None: + return _err("not-found", "workspace not found") + + await self.table_store.update_workspace( + id=v.workspace_record.id, + name=row[1] or v.workspace_record.id, + enabled=False, + ) + + user_rows = await self.table_store.list_users_by_workspace( + v.workspace_record.id, + ) + for ur in user_rows: + user_id = ur[0] + await self.table_store.update_user_enabled( + id=user_id, enabled=False, + ) + key_rows = await self.table_store.list_api_keys_by_user(user_id) + for kr in key_rows: + await self.table_store.delete_api_key(kr[0]) + + return IamResponse() + + # ------------------------------------------------------------------ + # rotate-signing-key + # ------------------------------------------------------------------ + + async def handle_rotate_signing_key(self, v): + """Create a new Ed25519 signing key, retire the current + active key, switch the in-memory cache over. + + The retired key row is kept in ``iam_signing_keys`` so the + gateway's JWT validator can continue to validate previously- + issued tokens during the grace period. Actual grace-period + enforcement (time-window acceptance at the validator) lands + with the gateway auth middleware work.""" + + # Retire the currently-active key, if any. + current = await self._get_active_signing_key() + now = _now_dt() + if current is not None: + cur_kid, _cur_priv, _cur_pub = current + await self.table_store.retire_signing_key( + kid=cur_kid, retired=now, + ) + + new_kid, new_priv, new_pub = _generate_signing_keypair() + await self.table_store.put_signing_key( + kid=new_kid, + private_pem=new_priv, + public_pem=new_pub, + created=now, + retired=None, + ) + self._signing_key = (new_kid, new_priv, new_pub) + logger.info( + f"IAM: rotated signing key. " + f"New kid={new_kid}, retired kid={(current or (None,))[0]}" + ) + return IamResponse() + # ------------------------------------------------------------------ # resolve-api-key # ------------------------------------------------------------------ diff --git a/trustgraph-flow/trustgraph/tables/iam.py b/trustgraph-flow/trustgraph/tables/iam.py index 5c9f578a..0f4966fe 100644 --- a/trustgraph-flow/trustgraph/tables/iam.py +++ b/trustgraph-flow/trustgraph/tables/iam.py @@ -217,6 +217,29 @@ class IamTableStore: SELECT kid, private_pem, public_pem, created, retired FROM iam_signing_keys """) + self.retire_signing_key_stmt = c.prepare(""" + UPDATE iam_signing_keys SET retired = ? WHERE kid = ? + """) + + self.update_user_profile_stmt = c.prepare(""" + UPDATE iam_users + SET name = ?, email = ?, roles = ?, enabled = ?, + must_change_password = ? + WHERE id = ? + """) + self.update_user_password_stmt = c.prepare(""" + UPDATE iam_users + SET password_hash = ?, must_change_password = ? + WHERE id = ? + """) + self.update_user_enabled_stmt = c.prepare(""" + UPDATE iam_users SET enabled = ? WHERE id = ? + """) + + self.update_workspace_stmt = c.prepare(""" + UPDATE iam_workspaces SET name = ?, enabled = ? + WHERE id = ? + """) # ------------------------------------------------------------------ # Workspaces @@ -330,6 +353,52 @@ class IamTableStore: self.cassandra, self.list_signing_keys_stmt, ) + async def retire_signing_key(self, kid, retired): + await async_execute( + self.cassandra, self.retire_signing_key_stmt, + (retired, kid), + ) + + # ------------------------------------------------------------------ + # User partial updates + # ------------------------------------------------------------------ + + async def update_user_profile( + self, id, name, email, roles, enabled, must_change_password, + ): + await async_execute( + self.cassandra, self.update_user_profile_stmt, + ( + name, email, + set(roles) if roles else set(), + enabled, must_change_password, id, + ), + ) + + async def update_user_password( + self, id, password_hash, must_change_password, + ): + await async_execute( + self.cassandra, self.update_user_password_stmt, + (password_hash, must_change_password, id), + ) + + async def update_user_enabled(self, id, enabled): + await async_execute( + self.cassandra, self.update_user_enabled_stmt, + (enabled, id), + ) + + # ------------------------------------------------------------------ + # Workspace updates + # ------------------------------------------------------------------ + + async def update_workspace(self, id, name, enabled): + await async_execute( + self.cassandra, self.update_workspace_stmt, + (name, enabled, id), + ) + # ------------------------------------------------------------------ # Bootstrap helpers # ------------------------------------------------------------------