Rest of the IAM operations

This commit is contained in:
Cyber MacGeddon 2026-04-23 14:20:32 +01:00
parent 1f639642ac
commit 832a030703
3 changed files with 545 additions and 0 deletions

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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
# ------------------------------------------------------------------