mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-04-27 17:36:23 +02:00
Rest of the IAM operations
This commit is contained in:
parent
1f639642ac
commit
832a030703
3 changed files with 545 additions and 0 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue