trustgraph/trustgraph-flow/trustgraph/iam/service/iam.py
Cyber MacGeddon 9ae79ff712 Updated CLI
2026-04-24 12:41:46 +01:00

1090 lines
38 KiB
Python

"""
IAM business logic. Handles ``IamRequest`` messages and builds
``IamResponse`` messages. Does not concern itself with transport.
See docs/tech-specs/iam-protocol.md for the wire-level contract and
docs/tech-specs/iam.md for the surrounding architecture.
"""
import asyncio
import base64
import datetime
import hashlib
import json
import logging
import os
import secrets
import uuid
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from trustgraph.schema import (
IamResponse, Error,
UserRecord, WorkspaceRecord, ApiKeyRecord,
)
from ... tables.iam import IamTableStore
logger = logging.getLogger(__name__)
DEFAULT_WORKSPACE = "default"
BOOTSTRAP_ADMIN_USERNAME = "admin"
BOOTSTRAP_ADMIN_NAME = "Administrator"
PBKDF2_ITERATIONS = 600_000
API_KEY_PREFIX = "tg_"
API_KEY_RANDOM_BYTES = 24
JWT_ISSUER = "trustgraph-iam"
JWT_TTL_SECONDS = 3600
def _now_iso():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def _now_dt():
return datetime.datetime.now(datetime.timezone.utc)
def _iso(dt):
if dt is None:
return ""
if isinstance(dt, str):
return dt
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
return dt.isoformat()
def _hash_password(password):
"""Return an encoded PBKDF2-SHA-256 hash of ``password``.
Format: ``pbkdf2-sha256$<iters>$<b64-salt>$<b64-hash>``. Stored
verbatim in the password_hash column so the algorithm and cost
can be evolved later (new rows get a new prefix; old rows are
verified with their own parameters).
"""
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS,
)
return (
f"pbkdf2-sha256${PBKDF2_ITERATIONS}"
f"${base64.b64encode(salt).decode('ascii')}"
f"${base64.b64encode(dk).decode('ascii')}"
)
def _verify_password(password, encoded):
"""Constant-time verify ``password`` against an encoded hash."""
try:
algo, iters, b64_salt, b64_hash = encoded.split("$")
except ValueError:
return False
if algo != "pbkdf2-sha256":
return False
try:
iters = int(iters)
salt = base64.b64decode(b64_salt)
target = base64.b64decode(b64_hash)
except Exception:
return False
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iters,
)
return secrets.compare_digest(dk, target)
def _generate_api_key():
"""Return a fresh API-key plaintext of the form ``tg_<random>``."""
return API_KEY_PREFIX + secrets.token_urlsafe(API_KEY_RANDOM_BYTES)
def _hash_api_key(plaintext):
"""SHA-256 hex digest of an API key plaintext. Used as the
primary key in ``iam_api_keys`` so ``resolve-api-key`` is O(1)."""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def _err(type, message):
return IamResponse(error=Error(type=type, message=message))
def _parse_expires(s):
if not s:
return None
try:
return datetime.datetime.fromisoformat(s)
except Exception:
return None
def _b64url(data):
"""URL-safe base64 encode without padding, as required by JWT."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _generate_signing_keypair():
"""Return (kid, private_pem, public_pem) for a fresh Ed25519
keypair. Ed25519 / EdDSA: small (32-byte public key), fast,
deterministic, side-channel-resistant by construction, free of
NIST-curve baggage."""
key = ed25519.Ed25519PrivateKey.generate()
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("ascii")
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
kid = uuid.uuid4().hex[:16]
return kid, private_pem, public_pem
def _sign_jwt(kid, private_pem, claims):
"""Produce a compact-serialisation EdDSA (Ed25519) JWT for
``claims``."""
key = serialization.load_pem_private_key(
private_pem.encode("ascii"), password=None,
)
if not isinstance(key, ed25519.Ed25519PrivateKey):
raise RuntimeError(
f"signing key is not Ed25519: {type(key).__name__}"
)
header = {"alg": "EdDSA", "typ": "JWT", "kid": kid}
header_b = _b64url(json.dumps(
header, separators=(",", ":"), sort_keys=True,
).encode("utf-8"))
payload_b = _b64url(json.dumps(
claims, separators=(",", ":"), sort_keys=True,
).encode("utf-8"))
signing_input = f"{header_b}.{payload_b}".encode("ascii")
signature = key.sign(signing_input)
return f"{header_b}.{payload_b}.{_b64url(signature)}"
class IamService:
def __init__(self, host, username, password, keyspace,
bootstrap_mode, bootstrap_token=None):
self.table_store = IamTableStore(
host, username, password, keyspace,
)
# bootstrap_mode: "token" or "bootstrap". In "token" mode the
# service auto-seeds on first start using the provided
# bootstrap_token and the ``bootstrap`` operation is refused
# thereafter (indistinguishable from an already-bootstrapped
# deployment per the error policy). In "bootstrap" mode the
# ``bootstrap`` operation is live until tables are populated.
if bootstrap_mode not in ("token", "bootstrap"):
raise ValueError(
f"bootstrap_mode must be 'token' or 'bootstrap', "
f"got {bootstrap_mode!r}"
)
if bootstrap_mode == "token" and not bootstrap_token:
raise ValueError(
"bootstrap_mode='token' requires bootstrap_token"
)
self.bootstrap_mode = bootstrap_mode
self.bootstrap_token = bootstrap_token
self._signing_key = None
self._signing_key_lock = asyncio.Lock()
# ------------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------------
async def handle(self, v):
op = v.operation
try:
if op == "bootstrap":
return await self.handle_bootstrap(v)
if op == "resolve-api-key":
return await self.handle_resolve_api_key(v)
if op == "create-user":
return await self.handle_create_user(v)
if op == "list-users":
return await self.handle_list_users(v)
if op == "create-api-key":
return await self.handle_create_api_key(v)
if op == "list-api-keys":
return await self.handle_list_api_keys(v)
if op == "revoke-api-key":
return await self.handle_revoke_api_key(v)
if op == "login":
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 == "enable-user":
return await self.handle_enable_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",
f"unknown or not-yet-implemented operation: {op!r}",
)
except Exception as e:
logger.error(
f"IAM {op} failed: {type(e).__name__}: {e}",
exc_info=True,
)
return _err("internal-error", str(e))
# ------------------------------------------------------------------
# Record conversion
# ------------------------------------------------------------------
def _row_to_user_record(self, row):
(
id, workspace, username, name, email, _password_hash,
roles, enabled, must_change_password, created,
) = row
return UserRecord(
id=id or "",
workspace=workspace or "",
username=username or "",
name=name or "",
email=email or "",
roles=sorted(roles) if roles else [],
enabled=bool(enabled),
must_change_password=bool(must_change_password),
created=_iso(created),
)
def _row_to_api_key_record(self, row):
(
_key_hash, id, user_id, name, prefix, expires,
created, last_used,
) = row
return ApiKeyRecord(
id=id or "",
user_id=user_id or "",
name=name or "",
prefix=prefix or "",
expires=_iso(expires),
created=_iso(created),
last_used=_iso(last_used),
)
# ------------------------------------------------------------------
# bootstrap
# ------------------------------------------------------------------
async def auto_bootstrap_if_token_mode(self):
"""Called from the service processor at startup. In
``token`` mode, if tables are empty, seeds the default
workspace / admin / signing key using the operator-provided
bootstrap token. The admin's API key plaintext is *the*
``bootstrap_token`` — the operator already knows it, nothing
needs to be returned or logged.
In ``bootstrap`` mode this is a no-op; seeding happens on
explicit ``bootstrap`` operation invocation."""
if self.bootstrap_mode != "token":
return
if await self.table_store.any_workspace_exists():
logger.info(
"IAM: token mode, tables already populated; skipping "
"auto-bootstrap"
)
return
logger.info("IAM: token mode, empty tables; auto-bootstrapping")
await self._seed_tables(self.bootstrap_token)
logger.info(
"IAM: auto-bootstrap complete using operator-provided token"
)
async def _seed_tables(self, api_key_plaintext):
"""Shared seeding logic used by token-mode auto-bootstrap and
bootstrap-mode handle_bootstrap. Creates the default
workspace, admin user, admin API key (using the given
plaintext), and an initial signing key. Returns the admin
user id."""
now = _now_dt()
await self.table_store.put_workspace(
id=DEFAULT_WORKSPACE,
name="Default",
enabled=True,
created=now,
)
admin_user_id = str(uuid.uuid4())
admin_password = secrets.token_urlsafe(32)
await self.table_store.put_user(
id=admin_user_id,
workspace=DEFAULT_WORKSPACE,
username=BOOTSTRAP_ADMIN_USERNAME,
name=BOOTSTRAP_ADMIN_NAME,
email="",
password_hash=_hash_password(admin_password),
roles=["admin"],
enabled=True,
must_change_password=True,
created=now,
)
key_id = str(uuid.uuid4())
await self.table_store.put_api_key(
key_hash=_hash_api_key(api_key_plaintext),
id=key_id,
user_id=admin_user_id,
name="bootstrap",
prefix=api_key_plaintext[:len(API_KEY_PREFIX) + 4],
expires=None,
created=now,
last_used=None,
)
kid, private_pem, public_pem = _generate_signing_keypair()
await self.table_store.put_signing_key(
kid=kid,
private_pem=private_pem,
public_pem=public_pem,
created=now,
retired=None,
)
self._signing_key = (kid, private_pem, public_pem)
logger.info(
f"IAM seeded: workspace={DEFAULT_WORKSPACE!r}, "
f"admin user_id={admin_user_id}, signing key kid={kid}"
)
return admin_user_id
async def handle_bootstrap(self, v):
"""Explicit bootstrap op. Only available in ``bootstrap``
mode and only when tables are empty. Every other case is
masked to a generic auth failure — the caller cannot
distinguish 'not in bootstrap mode' from 'already
bootstrapped' from 'operation forbidden'."""
if self.bootstrap_mode != "bootstrap":
return _err("auth-failed", "auth failure")
if await self.table_store.any_workspace_exists():
return _err("auth-failed", "auth failure")
plaintext = _generate_api_key()
admin_user_id = await self._seed_tables(plaintext)
return IamResponse(
bootstrap_admin_user_id=admin_user_id,
bootstrap_admin_api_key=plaintext,
)
# ------------------------------------------------------------------
# Signing key helpers
# ------------------------------------------------------------------
async def _get_active_signing_key(self):
"""Return ``(kid, private_pem, public_pem)`` for the active
signing key. Loads from Cassandra on first call. Generates
and persists a new key if none exists — covers the case where
``login`` is called before ``bootstrap`` (shouldn't happen in
practice but keeps the service internally consistent)."""
if self._signing_key is not None:
return self._signing_key
async with self._signing_key_lock:
if self._signing_key is not None:
return self._signing_key
rows = await self.table_store.list_signing_keys()
active = [r for r in rows if r[4] is None]
if active:
row = active[0]
self._signing_key = (row[0], row[1], row[2])
logger.info(
f"IAM: loaded active signing key kid={row[0]}"
)
return self._signing_key
kid, private_pem, public_pem = _generate_signing_keypair()
await self.table_store.put_signing_key(
kid=kid,
private_pem=private_pem,
public_pem=public_pem,
created=_now_dt(),
retired=None,
)
self._signing_key = (kid, private_pem, public_pem)
logger.info(
f"IAM: generated active signing key kid={kid} "
f"(no existing key found)"
)
return self._signing_key
# ------------------------------------------------------------------
# login
# ------------------------------------------------------------------
async def handle_login(self, v):
if not v.username:
return _err("auth-failed", "username required")
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,
)
if not user_id:
return _err("auth-failed", "no such user")
user_row = await self.table_store.get_user(user_id)
if user_row is None:
return _err("auth-failed", "user disappeared")
(
id, ws, _username, _name, _email, password_hash,
roles, enabled, _mcp, _created,
) = 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")
ws_row = await self.table_store.get_workspace(ws)
if ws_row is None or not ws_row[2]:
return _err("auth-failed", "workspace disabled")
kid, private_pem, _ = await self._get_active_signing_key()
now_ts = int(_now_dt().timestamp())
exp_ts = now_ts + JWT_TTL_SECONDS
claims = {
"iss": JWT_ISSUER,
"sub": id,
"workspace": ws,
"roles": sorted(roles) if roles else [],
"iat": now_ts,
"exp": exp_ts,
}
token = _sign_jwt(kid, private_pem, claims)
expires_iso = datetime.datetime.fromtimestamp(
exp_ts, tz=datetime.timezone.utc,
).isoformat()
return IamResponse(jwt=token, jwt_expires=expires_iso)
# ------------------------------------------------------------------
# get-signing-key-public
# ------------------------------------------------------------------
async def handle_get_signing_key_public(self, v):
_, _, 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()
async def handle_enable_user(self, v):
"""Re-enable a previously disabled user. Does not restore
API keys — those have to be re-issued by the admin."""
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=True,
)
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
# ------------------------------------------------------------------
async def handle_resolve_api_key(self, v):
if not v.api_key:
return _err("auth-failed", "no api key")
row = await self.table_store.get_api_key_by_hash(
_hash_api_key(v.api_key),
)
if row is None:
return _err("auth-failed", "unknown api key")
(
_key_hash, _id, user_id, _name, _prefix, expires,
_created, _last_used,
) = row
if expires is not None:
exp_dt = expires
if isinstance(exp_dt, str):
exp_dt = datetime.datetime.fromisoformat(exp_dt)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=datetime.timezone.utc)
if exp_dt < _now_dt():
return _err("auth-failed", "api key expired")
user_row = await self.table_store.get_user(user_id)
if user_row is None:
return _err("auth-failed", "owning user missing")
user = self._row_to_user_record(user_row)
if not user.enabled:
return _err("auth-failed", "owning user disabled")
# Workspace-disabled check.
ws_row = await self.table_store.get_workspace(user.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_roles=list(user.roles),
)
# ------------------------------------------------------------------
# create-user
# ------------------------------------------------------------------
async def handle_create_user(self, v):
if not v.workspace:
return _err(
"invalid-argument", "workspace required for create-user",
)
if v.user is None:
return _err(
"invalid-argument", "user field required for create-user",
)
if not v.user.username:
return _err("invalid-argument", "user.username required")
if not v.user.password:
return _err("invalid-argument", "user.password required")
# Workspace must exist and be enabled.
ws = await self.table_store.get_workspace(v.workspace)
if ws is None or not ws[2]:
return _err("not-found", "workspace not found or disabled")
# Uniqueness on username within workspace.
existing = await self.table_store.get_user_id_by_username(
v.workspace, v.user.username,
)
if existing:
return _err("duplicate", "username already exists")
user_id = str(uuid.uuid4())
now = _now_dt()
await self.table_store.put_user(
id=user_id,
workspace=v.workspace,
username=v.user.username,
name=v.user.name or v.user.username,
email=v.user.email or "",
password_hash=_hash_password(v.user.password),
roles=list(v.user.roles or []),
enabled=v.user.enabled,
must_change_password=v.user.must_change_password,
created=now,
)
row = await self.table_store.get_user(user_id)
return IamResponse(user=self._row_to_user_record(row))
# ------------------------------------------------------------------
# list-users
# ------------------------------------------------------------------
async def handle_list_users(self, v):
if not v.workspace:
return _err(
"invalid-argument", "workspace required for list-users",
)
rows = await self.table_store.list_users_by_workspace(v.workspace)
return IamResponse(
users=[self._row_to_user_record(r) for r in rows],
)
# ------------------------------------------------------------------
# create-api-key
# ------------------------------------------------------------------
async def handle_create_api_key(self, v):
if not v.workspace:
return _err(
"invalid-argument", "workspace required for create-api-key",
)
if v.key is None or not v.key.user_id:
return _err("invalid-argument", "key.user_id required")
if not v.key.name:
return _err("invalid-argument", "key.name required")
# Target user must exist and belong to the caller's workspace.
user_row = await self.table_store.get_user(v.key.user_id)
if user_row is None:
return _err("not-found", "user not found")
if user_row[1] != v.workspace:
return _err(
"operation-not-permitted",
"target user is in a different workspace",
)
plaintext = _generate_api_key()
key_id = str(uuid.uuid4())
now = _now_dt()
expires_dt = _parse_expires(v.key.expires)
await self.table_store.put_api_key(
key_hash=_hash_api_key(plaintext),
id=key_id,
user_id=v.key.user_id,
name=v.key.name,
prefix=plaintext[:len(API_KEY_PREFIX) + 4],
expires=expires_dt,
created=now,
last_used=None,
)
row = await self.table_store.get_api_key_by_hash(
_hash_api_key(plaintext),
)
return IamResponse(
api_key_plaintext=plaintext,
api_key=self._row_to_api_key_record(row),
)
# ------------------------------------------------------------------
# list-api-keys
# ------------------------------------------------------------------
async def handle_list_api_keys(self, v):
if not v.workspace:
return _err(
"invalid-argument",
"workspace required for list-api-keys",
)
if not v.user_id:
return _err(
"invalid-argument", "user_id required for list-api-keys",
)
# Workspace-scope check: user must live in this workspace.
user_row = await self.table_store.get_user(v.user_id)
if user_row is None or user_row[1] != v.workspace:
return _err("not-found", "user not found in workspace")
rows = await self.table_store.list_api_keys_by_user(v.user_id)
return IamResponse(
api_keys=[self._row_to_api_key_record(r) for r in rows],
)
# ------------------------------------------------------------------
# revoke-api-key
# ------------------------------------------------------------------
async def handle_revoke_api_key(self, v):
if not v.workspace:
return _err(
"invalid-argument",
"workspace required for revoke-api-key",
)
if not v.key_id:
return _err("invalid-argument", "key_id required")
row = await self.table_store.get_api_key_by_id(v.key_id)
if row is None:
return _err("not-found", "api key not found")
key_hash, _id, user_id, _name, _prefix, _expires, _c, _lu = row
# Workspace-scope check via the owning user.
user_row = await self.table_store.get_user(user_id)
if user_row is None or user_row[1] != v.workspace:
return _err(
"operation-not-permitted",
"key belongs to a different workspace",
)
await self.table_store.delete_api_key(key_hash)
return IamResponse()