IAM implementation

This commit is contained in:
Cyber MacGeddon 2026-04-23 12:46:34 +01:00
parent 762a51e214
commit 2f8fce4030
14 changed files with 1486 additions and 2 deletions

View file

@ -63,6 +63,7 @@ chunker-token = "trustgraph.chunking.token:run"
bootstrap = "trustgraph.bootstrap.bootstrapper:run"
config-svc = "trustgraph.config.service:run"
flow-svc = "trustgraph.flow.service:run"
iam-svc = "trustgraph.iam.service:run"
doc-embeddings-query-milvus = "trustgraph.query.doc_embeddings.milvus:run"
doc-embeddings-query-pinecone = "trustgraph.query.doc_embeddings.pinecone:run"
doc-embeddings-query-qdrant = "trustgraph.query.doc_embeddings.qdrant:run"

View file

@ -0,0 +1,40 @@
from ... schema import IamRequest, IamResponse
from ... schema import iam_request_queue, iam_response_queue
from ... messaging import TranslatorRegistry
from . requestor import ServiceRequestor
class IamRequestor(ServiceRequestor):
def __init__(self, backend, consumer, subscriber, timeout=120,
request_queue=None, response_queue=None):
if request_queue is None:
request_queue = iam_request_queue
if response_queue is None:
response_queue = iam_response_queue
super().__init__(
backend=backend,
consumer_name=consumer,
subscription=subscriber,
request_queue=request_queue,
response_queue=response_queue,
request_schema=IamRequest,
response_schema=IamResponse,
timeout=timeout,
)
self.request_translator = (
TranslatorRegistry.get_request_translator("iam")
)
self.response_translator = (
TranslatorRegistry.get_response_translator("iam")
)
def to_request(self, body):
return self.request_translator.decode(body)
def from_response(self, message):
return self.response_translator.encode_with_completion(message)

View file

@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
from . config import ConfigRequestor
from . flow import FlowRequestor
from . iam import IamRequestor
from . librarian import LibrarianRequestor
from . knowledge import KnowledgeRequestor
from . collection_management import CollectionManagementRequestor
@ -72,6 +73,7 @@ request_response_dispatchers = {
global_dispatchers = {
"config": ConfigRequestor,
"flow": FlowRequestor,
"iam": IamRequestor,
"librarian": LibrarianRequestor,
"knowledge": KnowledgeRequestor,
"collection-management": CollectionManagementRequestor,

View file

@ -0,0 +1,4 @@
from . service import run
__all__ = ["run"]

View file

@ -0,0 +1,4 @@
from . service import run
run()

View file

@ -0,0 +1,474 @@
"""
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 base64
import datetime
import hashlib
import logging
import os
import secrets
import uuid
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
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
class IamService:
def __init__(self, host, username, password, keyspace):
self.table_store = IamTableStore(
host, username, password, keyspace,
)
# ------------------------------------------------------------------
# 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)
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 handle_bootstrap(self, v):
"""No-op if any workspace already exists. Otherwise create
the ``default`` workspace, an ``admin`` user with role
``admin``, and an initial API key for that admin. The
plaintext API key is returned once in the response."""
if await self.table_store.any_workspace_exists():
logger.info(
"IAM bootstrap: tables already populated; no-op"
)
return IamResponse()
now = _now_dt()
# Workspace.
await self.table_store.put_workspace(
id=DEFAULT_WORKSPACE,
name="Default",
enabled=True,
created=now,
)
# Admin user.
admin_user_id = str(uuid.uuid4())
# Password is set to a random unusable value; admin logs in
# with the API key below. Password login for this user can be
# enabled later by reset-password.
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,
)
# Admin API key.
plaintext = _generate_api_key()
key_id = str(uuid.uuid4())
await self.table_store.put_api_key(
key_hash=_hash_api_key(plaintext),
id=key_id,
user_id=admin_user_id,
name="bootstrap",
prefix=plaintext[:len(API_KEY_PREFIX) + 4],
expires=None,
created=now,
last_used=None,
)
logger.info(
f"IAM bootstrap: created workspace={DEFAULT_WORKSPACE!r}, "
f"admin user_id={admin_user_id}, initial API key issued"
)
return IamResponse(
bootstrap_admin_user_id=admin_user_id,
bootstrap_admin_api_key=plaintext,
)
# ------------------------------------------------------------------
# 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()

View file

@ -0,0 +1,152 @@
"""
IAM service processor. Terminates the IAM request queue and forwards
each request to the IamService business logic, then returns the
response on the IAM response queue.
Shape mirrors trustgraph.config.service.
"""
import logging
from trustgraph.schema import Error
from trustgraph.schema import IamRequest, IamResponse
from trustgraph.schema import iam_request_queue, iam_response_queue
from trustgraph.base import AsyncProcessor, Consumer, Producer
from trustgraph.base import ConsumerMetrics, ProducerMetrics
from trustgraph.base.cassandra_config import (
add_cassandra_args, resolve_cassandra_config,
)
from . iam import IamService
logger = logging.getLogger(__name__)
default_ident = "iam-svc"
default_iam_request_queue = iam_request_queue
default_iam_response_queue = iam_response_queue
class Processor(AsyncProcessor):
def __init__(self, **params):
iam_req_q = params.get(
"iam_request_queue", default_iam_request_queue,
)
iam_resp_q = params.get(
"iam_response_queue", default_iam_response_queue,
)
cassandra_host = params.get("cassandra_host")
cassandra_username = params.get("cassandra_username")
cassandra_password = params.get("cassandra_password")
hosts, username, password, keyspace = resolve_cassandra_config(
host=cassandra_host,
username=cassandra_username,
password=cassandra_password,
default_keyspace="iam",
)
self.cassandra_host = hosts
self.cassandra_username = username
self.cassandra_password = password
super().__init__(
**params | {
"iam_request_schema": IamRequest.__name__,
"iam_response_schema": IamResponse.__name__,
"cassandra_host": self.cassandra_host,
"cassandra_username": self.cassandra_username,
"cassandra_password": self.cassandra_password,
}
)
iam_request_metrics = ConsumerMetrics(
processor=self.id, flow=None, name="iam-request",
)
iam_response_metrics = ProducerMetrics(
processor=self.id, flow=None, name="iam-response",
)
self.iam_request_topic = iam_req_q
self.iam_request_consumer = Consumer(
taskgroup=self.taskgroup,
backend=self.pubsub,
flow=None,
topic=iam_req_q,
subscriber=self.id,
schema=IamRequest,
handler=self.on_iam_request,
metrics=iam_request_metrics,
)
self.iam_response_producer = Producer(
backend=self.pubsub,
topic=iam_resp_q,
schema=IamResponse,
metrics=iam_response_metrics,
)
self.iam = IamService(
host=self.cassandra_host,
username=self.cassandra_username,
password=self.cassandra_password,
keyspace=keyspace,
)
logger.info("IAM service initialised")
async def start(self):
await self.pubsub.ensure_topic(self.iam_request_topic)
await self.iam_request_consumer.start()
async def on_iam_request(self, msg, consumer, flow):
id = None
try:
v = msg.value()
id = msg.properties()["id"]
logger.debug(
f"Handling IAM request {id} op={v.operation!r}"
)
resp = await self.iam.handle(v)
await self.iam_response_producer.send(
resp, properties={"id": id},
)
except Exception as e:
logger.error(
f"IAM request failed: {type(e).__name__}: {e}",
exc_info=True,
)
resp = IamResponse(
error=Error(type="internal-error", message=str(e)),
)
if id is not None:
await self.iam_response_producer.send(
resp, properties={"id": id},
)
@staticmethod
def add_args(parser):
AsyncProcessor.add_args(parser)
parser.add_argument(
"--iam-request-queue",
default=default_iam_request_queue,
help=f"IAM request queue (default: {default_iam_request_queue})",
)
parser.add_argument(
"--iam-response-queue",
default=default_iam_response_queue,
help=f"IAM response queue (default: {default_iam_response_queue})",
)
add_cassandra_args(parser)
def run():
Processor.launch(default_ident, __doc__)

View file

@ -0,0 +1,339 @@
"""
IAM Cassandra table store.
Tables:
- iam_workspaces (id primary key)
- iam_users (id primary key) + iam_users_by_username lookup table
(workspace, username) -> id
- iam_api_keys (key_hash primary key) with secondary index on user_id
- iam_signing_keys (kid primary key) RSA keypairs for JWT signing
See docs/tech-specs/iam-protocol.md for the wire-level context.
"""
import logging
from cassandra.cluster import Cluster
from cassandra.auth import PlainTextAuthProvider
from ssl import SSLContext, PROTOCOL_TLSv1_2
from . cassandra_async import async_execute
logger = logging.getLogger(__name__)
class IamTableStore:
def __init__(
self,
cassandra_host, cassandra_username, cassandra_password,
keyspace,
):
self.keyspace = keyspace
logger.info("IAM: connecting to Cassandra...")
if isinstance(cassandra_host, str):
cassandra_host = [h.strip() for h in cassandra_host.split(",")]
if cassandra_username and cassandra_password:
ssl_context = SSLContext(PROTOCOL_TLSv1_2)
auth_provider = PlainTextAuthProvider(
username=cassandra_username, password=cassandra_password,
)
self.cluster = Cluster(
cassandra_host,
auth_provider=auth_provider,
ssl_context=ssl_context,
)
else:
self.cluster = Cluster(cassandra_host)
self.cassandra = self.cluster.connect()
logger.info("IAM: connected.")
self._ensure_schema()
self._prepare_statements()
def _ensure_schema(self):
# FIXME: Replication factor should be configurable.
self.cassandra.execute(f"""
create keyspace if not exists {self.keyspace}
with replication = {{
'class' : 'SimpleStrategy',
'replication_factor' : 1
}};
""")
self.cassandra.set_keyspace(self.keyspace)
self.cassandra.execute("""
CREATE TABLE IF NOT EXISTS iam_workspaces (
id text PRIMARY KEY,
name text,
enabled boolean,
created timestamp
);
""")
self.cassandra.execute("""
CREATE TABLE IF NOT EXISTS iam_users (
id text PRIMARY KEY,
workspace text,
username text,
name text,
email text,
password_hash text,
roles set<text>,
enabled boolean,
must_change_password boolean,
created timestamp
);
""")
self.cassandra.execute("""
CREATE TABLE IF NOT EXISTS iam_users_by_username (
workspace text,
username text,
user_id text,
PRIMARY KEY ((workspace), username)
);
""")
self.cassandra.execute("""
CREATE TABLE IF NOT EXISTS iam_api_keys (
key_hash text PRIMARY KEY,
id text,
user_id text,
name text,
prefix text,
expires timestamp,
created timestamp,
last_used timestamp
);
""")
self.cassandra.execute("""
CREATE INDEX IF NOT EXISTS iam_api_keys_user_id_idx
ON iam_api_keys (user_id);
""")
self.cassandra.execute("""
CREATE INDEX IF NOT EXISTS iam_api_keys_id_idx
ON iam_api_keys (id);
""")
self.cassandra.execute("""
CREATE TABLE IF NOT EXISTS iam_signing_keys (
kid text PRIMARY KEY,
private_pem text,
public_pem text,
created timestamp,
retired timestamp
);
""")
logger.info("IAM: Cassandra schema OK.")
def _prepare_statements(self):
c = self.cassandra
self.put_workspace_stmt = c.prepare("""
INSERT INTO iam_workspaces (id, name, enabled, created)
VALUES (?, ?, ?, ?)
""")
self.get_workspace_stmt = c.prepare("""
SELECT id, name, enabled, created FROM iam_workspaces
WHERE id = ?
""")
self.list_workspaces_stmt = c.prepare("""
SELECT id, name, enabled, created FROM iam_workspaces
""")
self.put_user_stmt = c.prepare("""
INSERT INTO iam_users (
id, workspace, username, name, email, password_hash,
roles, enabled, must_change_password, created
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""")
self.get_user_stmt = c.prepare("""
SELECT id, workspace, username, name, email, password_hash,
roles, enabled, must_change_password, created
FROM iam_users WHERE id = ?
""")
self.list_users_by_workspace_stmt = c.prepare("""
SELECT id, workspace, username, name, email, password_hash,
roles, enabled, must_change_password, created
FROM iam_users WHERE workspace = ? ALLOW FILTERING
""")
self.put_username_lookup_stmt = c.prepare("""
INSERT INTO iam_users_by_username (workspace, 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 = ?
""")
self.delete_username_lookup_stmt = c.prepare("""
DELETE FROM iam_users_by_username
WHERE workspace = ? AND username = ?
""")
self.put_api_key_stmt = c.prepare("""
INSERT INTO iam_api_keys (
key_hash, id, user_id, name, prefix, expires,
created, last_used
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""")
self.get_api_key_by_hash_stmt = c.prepare("""
SELECT key_hash, id, user_id, name, prefix, expires,
created, last_used
FROM iam_api_keys WHERE key_hash = ?
""")
self.get_api_key_by_id_stmt = c.prepare("""
SELECT key_hash, id, user_id, name, prefix, expires,
created, last_used
FROM iam_api_keys WHERE id = ?
""")
self.list_api_keys_by_user_stmt = c.prepare("""
SELECT key_hash, id, user_id, name, prefix, expires,
created, last_used
FROM iam_api_keys WHERE user_id = ?
""")
self.delete_api_key_stmt = c.prepare("""
DELETE FROM iam_api_keys WHERE key_hash = ?
""")
self.put_signing_key_stmt = c.prepare("""
INSERT INTO iam_signing_keys (
kid, private_pem, public_pem, created, retired
)
VALUES (?, ?, ?, ?, ?)
""")
self.list_signing_keys_stmt = c.prepare("""
SELECT kid, private_pem, public_pem, created, retired
FROM iam_signing_keys
""")
# ------------------------------------------------------------------
# Workspaces
# ------------------------------------------------------------------
async def put_workspace(self, id, name, enabled, created):
await async_execute(
self.cassandra, self.put_workspace_stmt,
(id, name, enabled, created),
)
async def get_workspace(self, id):
rows = await async_execute(
self.cassandra, self.get_workspace_stmt, (id,),
)
return rows[0] if rows else None
async def list_workspaces(self):
return await async_execute(
self.cassandra, self.list_workspaces_stmt,
)
# ------------------------------------------------------------------
# Users
# ------------------------------------------------------------------
async def put_user(
self, id, workspace, username, name, email, password_hash,
roles, enabled, must_change_password, created,
):
await async_execute(
self.cassandra, self.put_user_stmt,
(
id, workspace, username, name, email, password_hash,
set(roles) if roles else set(),
enabled, must_change_password, created,
),
)
await async_execute(
self.cassandra, self.put_username_lookup_stmt,
(workspace, username, id),
)
async def get_user(self, id):
rows = await async_execute(
self.cassandra, self.get_user_stmt, (id,),
)
return rows[0] if rows else None
async def get_user_id_by_username(self, workspace, username):
rows = await async_execute(
self.cassandra, self.get_user_id_by_username_stmt,
(workspace, username),
)
return rows[0][0] if rows else None
async def list_users_by_workspace(self, workspace):
return await async_execute(
self.cassandra, self.list_users_by_workspace_stmt, (workspace,),
)
# ------------------------------------------------------------------
# API keys
# ------------------------------------------------------------------
async def put_api_key(
self, key_hash, id, user_id, name, prefix, expires,
created, last_used,
):
await async_execute(
self.cassandra, self.put_api_key_stmt,
(key_hash, id, user_id, name, prefix, expires,
created, last_used),
)
async def get_api_key_by_hash(self, key_hash):
rows = await async_execute(
self.cassandra, self.get_api_key_by_hash_stmt, (key_hash,),
)
return rows[0] if rows else None
async def get_api_key_by_id(self, id):
rows = await async_execute(
self.cassandra, self.get_api_key_by_id_stmt, (id,),
)
return rows[0] if rows else None
async def list_api_keys_by_user(self, user_id):
return await async_execute(
self.cassandra, self.list_api_keys_by_user_stmt, (user_id,),
)
async def delete_api_key(self, key_hash):
await async_execute(
self.cassandra, self.delete_api_key_stmt, (key_hash,),
)
# ------------------------------------------------------------------
# Signing keys
# ------------------------------------------------------------------
async def put_signing_key(self, kid, private_pem, public_pem,
created, retired):
await async_execute(
self.cassandra, self.put_signing_key_stmt,
(kid, private_pem, public_pem, created, retired),
)
async def list_signing_keys(self):
return await async_execute(
self.cassandra, self.list_signing_keys_stmt,
)
# ------------------------------------------------------------------
# Bootstrap helpers
# ------------------------------------------------------------------
async def any_workspace_exists(self):
rows = await self.list_workspaces()
return bool(rows)