1012 lines
38 KiB
Python
1012 lines
38 KiB
Python
"""Durable signed authorization chain for the AIOS baseline 1.1 runtime.
|
|
|
|
Owner grants are long-lived policy roots. The local authorization service
|
|
stores and verifies those roots, then issues short machine-signed operational
|
|
epochs and task credentials. Authorization always rereads durable grant state;
|
|
there is deliberately no revocation cache.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
from typing import Any, Callable, Mapping
|
|
import uuid
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
Ed25519PrivateKey,
|
|
Ed25519PublicKey,
|
|
)
|
|
|
|
|
|
GRANT_SCHEMA = "aios.owner-delegation-grant.v1"
|
|
EPOCH_SCHEMA = "aios.operational-epoch.v1"
|
|
PRINCIPAL_SCHEMA = "aios.task-principal.v1"
|
|
SIGNATURE_ALGORITHM = "Ed25519"
|
|
MAX_PRINCIPAL_TTL_SECONDS = 300
|
|
DESTRUCTIVE_CEILINGS = frozenset({"NONE", "REVERSIBLE", "IRREVERSIBLE"})
|
|
|
|
_TEXT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,255}$")
|
|
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
_DOMAIN = b"AIOS-AUTHORIZATION\x00"
|
|
_SCOPE_FIELDS = (
|
|
"mission_ids",
|
|
"task_ids",
|
|
"operations",
|
|
"component_ids",
|
|
"environments",
|
|
)
|
|
_BINDING_FIELDS = (
|
|
"tenant_id",
|
|
"subject_id",
|
|
"mission_id",
|
|
"task_id",
|
|
"operation",
|
|
"component_id",
|
|
"environment",
|
|
)
|
|
_ROOT_FIELDS = (
|
|
"root_grant_id",
|
|
"root_grant_digest",
|
|
"root_grant_generation",
|
|
)
|
|
|
|
|
|
class AuthorizationError(RuntimeError):
|
|
"""Raised when an authorization object or state transition is invalid."""
|
|
|
|
|
|
def _plain_object(value: Any, name: str) -> dict[str, Any]:
|
|
if type(value) is not dict:
|
|
raise AuthorizationError(f"{name} must be a plain object")
|
|
return value
|
|
|
|
|
|
def _exact_fields(value: dict[str, Any], expected: set[str], name: str) -> None:
|
|
if set(value) != expected:
|
|
raise AuthorizationError(f"{name} fields are invalid")
|
|
|
|
|
|
def _text(value: Any, name: str) -> str:
|
|
if type(value) is not str or not _TEXT_RE.fullmatch(value):
|
|
raise AuthorizationError(f"{name} is invalid")
|
|
return value
|
|
|
|
|
|
def _positive_int(value: Any, name: str) -> int:
|
|
if type(value) is not int or value <= 0:
|
|
raise AuthorizationError(f"{name} must be a positive integer")
|
|
return value
|
|
|
|
|
|
def _timestamp(value: Any, name: str) -> int:
|
|
return _positive_int(value, name)
|
|
|
|
|
|
def _canonical_json(value: Any) -> bytes:
|
|
try:
|
|
encoded = json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
)
|
|
except (TypeError, ValueError) as exc:
|
|
raise AuthorizationError("authorization object is not canonical JSON") from exc
|
|
return encoded.encode("utf-8")
|
|
|
|
|
|
def _json_copy(value: dict[str, Any]) -> dict[str, Any]:
|
|
copied = json.loads(_canonical_json(value))
|
|
return _plain_object(copied, "authorization envelope")
|
|
|
|
|
|
def _unsigned_envelope(envelope: dict[str, Any]) -> dict[str, Any]:
|
|
signing = _plain_object(envelope.get("signing"), "signing")
|
|
_exact_fields(signing, {"algorithm", "key_id", "signature"}, "signing")
|
|
unsigned = {key: value for key, value in envelope.items() if key != "signing"}
|
|
unsigned["signing"] = {
|
|
"algorithm": signing["algorithm"],
|
|
"key_id": signing["key_id"],
|
|
}
|
|
return unsigned
|
|
|
|
|
|
def _signature_message(envelope: dict[str, Any]) -> bytes:
|
|
schema = envelope.get("schema")
|
|
if type(schema) is not str:
|
|
raise AuthorizationError("authorization schema is invalid")
|
|
return _DOMAIN + schema.encode("ascii", "strict") + b"\x00" + _canonical_json(
|
|
_unsigned_envelope(envelope)
|
|
)
|
|
|
|
|
|
def _signature_bytes(value: Any) -> bytes:
|
|
if type(value) is not str or not value:
|
|
raise AuthorizationError("signature is invalid")
|
|
try:
|
|
decoded = base64.b64decode(value, validate=True)
|
|
except (ValueError, binascii.Error) as exc:
|
|
raise AuthorizationError("signature encoding is invalid") from exc
|
|
if len(decoded) != 64:
|
|
raise AuthorizationError("signature length is invalid")
|
|
return decoded
|
|
|
|
|
|
def _sign(
|
|
schema: str,
|
|
payload: dict[str, Any],
|
|
private_key: Ed25519PrivateKey,
|
|
key_id: str,
|
|
*,
|
|
extra: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
if not isinstance(private_key, Ed25519PrivateKey):
|
|
raise AuthorizationError("Ed25519 private key is required")
|
|
_text(key_id, "key_id")
|
|
envelope: dict[str, Any] = {"schema": schema, "payload": payload}
|
|
if extra:
|
|
envelope.update(extra)
|
|
envelope["signing"] = {
|
|
"algorithm": SIGNATURE_ALGORITHM,
|
|
"key_id": key_id,
|
|
"signature": "",
|
|
}
|
|
signature = private_key.sign(_signature_message(envelope))
|
|
envelope["signing"]["signature"] = base64.b64encode(signature).decode("ascii")
|
|
return envelope
|
|
|
|
|
|
def _verify_signature(
|
|
envelope: dict[str, Any],
|
|
public_keys: Mapping[str, Ed25519PublicKey],
|
|
*,
|
|
trust_name: str,
|
|
) -> None:
|
|
signing = _plain_object(envelope.get("signing"), "signing")
|
|
_exact_fields(signing, {"algorithm", "key_id", "signature"}, "signing")
|
|
if signing["algorithm"] != SIGNATURE_ALGORITHM:
|
|
raise AuthorizationError("signature algorithm is invalid")
|
|
key_id = _text(signing["key_id"], "signing.key_id")
|
|
key = public_keys.get(key_id)
|
|
if key is None:
|
|
raise AuthorizationError(f"signing key is not a trusted {trust_name} key")
|
|
try:
|
|
key.verify(_signature_bytes(signing["signature"]), _signature_message(envelope))
|
|
except InvalidSignature as exc:
|
|
raise AuthorizationError("authorization signature is invalid") from exc
|
|
|
|
|
|
def _scope_list(value: Any, name: str) -> list[str]:
|
|
if type(value) is not list or not value or len(value) > 256:
|
|
raise AuthorizationError(f"{name} must be a non-empty list")
|
|
checked: list[str] = []
|
|
for item in value:
|
|
if item == "*":
|
|
checked.append(item)
|
|
else:
|
|
checked.append(_text(item, name))
|
|
if len(set(checked)) != len(checked):
|
|
raise AuthorizationError(f"{name} contains duplicates")
|
|
if "*" in checked and checked != ["*"]:
|
|
raise AuthorizationError(f"{name} wildcard must be the only entry")
|
|
return checked
|
|
|
|
|
|
def _validate_grant_payload(payload: Any) -> dict[str, Any]:
|
|
value = _plain_object(payload, "grant payload")
|
|
_exact_fields(
|
|
value,
|
|
{
|
|
"grant_id",
|
|
"generation",
|
|
"tenant_id",
|
|
"subject_id",
|
|
"scope",
|
|
"issued_at",
|
|
"validity",
|
|
"limits",
|
|
"destructive_ceiling",
|
|
},
|
|
"grant payload",
|
|
)
|
|
_text(value["grant_id"], "grant_id")
|
|
_positive_int(value["generation"], "generation")
|
|
_text(value["tenant_id"], "tenant_id")
|
|
_text(value["subject_id"], "subject_id")
|
|
if type(value["destructive_ceiling"]) is not str or value["destructive_ceiling"] not in DESTRUCTIVE_CEILINGS:
|
|
raise AuthorizationError("destructive_ceiling is invalid")
|
|
issued_at = _timestamp(value["issued_at"], "issued_at")
|
|
|
|
scope = _plain_object(value["scope"], "grant scope")
|
|
_exact_fields(scope, set(_SCOPE_FIELDS), "grant scope")
|
|
for field in _SCOPE_FIELDS:
|
|
_scope_list(scope[field], f"scope.{field}")
|
|
|
|
validity = _plain_object(value["validity"], "grant validity")
|
|
mode = validity.get("mode")
|
|
if mode == "UNTIL_REVOKED":
|
|
_exact_fields(validity, {"mode"}, "grant validity")
|
|
elif mode == "EXPIRES_AT":
|
|
_exact_fields(validity, {"mode", "expires_at"}, "grant validity")
|
|
expires_at = _timestamp(validity["expires_at"], "expires_at")
|
|
if expires_at <= issued_at:
|
|
raise AuthorizationError("expires_at must be after issued_at")
|
|
else:
|
|
raise AuthorizationError("grant validity mode is invalid")
|
|
|
|
limits = _plain_object(value["limits"], "grant limits")
|
|
if set(limits) not in (set(), {"max_effects"}):
|
|
raise AuthorizationError("grant limits fields are invalid")
|
|
if "max_effects" in limits:
|
|
_positive_int(limits["max_effects"], "max_effects")
|
|
return value
|
|
|
|
|
|
def _validate_grant_envelope(
|
|
envelope: Any,
|
|
owner_public_keys: Mapping[str, Ed25519PublicKey],
|
|
owner_key_scopes: Mapping[str, Mapping[str, frozenset[str]]],
|
|
) -> dict[str, Any]:
|
|
value = _plain_object(envelope, "grant envelope")
|
|
_exact_fields(value, {"schema", "payload", "signing"}, "grant envelope")
|
|
if value["schema"] != GRANT_SCHEMA:
|
|
raise AuthorizationError("grant schema is invalid")
|
|
_validate_grant_payload(value["payload"])
|
|
_verify_signature(value, owner_public_keys, trust_name="owner")
|
|
key_id = value["signing"]["key_id"]
|
|
trust_scope = owner_key_scopes[key_id]
|
|
payload = value["payload"]
|
|
if (
|
|
"*" not in trust_scope["tenant_ids"]
|
|
and payload["tenant_id"] not in trust_scope["tenant_ids"]
|
|
) or (
|
|
"*" not in trust_scope["subject_ids"]
|
|
and payload["subject_id"] not in trust_scope["subject_ids"]
|
|
):
|
|
raise AuthorizationError("grant is outside trusted Owner key scope")
|
|
return value
|
|
|
|
|
|
def grant_digest(envelope: Mapping[str, Any]) -> str:
|
|
"""Return the canonical SHA-256 digest of a signed grant envelope."""
|
|
|
|
value = _plain_object(envelope, "grant envelope")
|
|
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
|
|
|
|
|
def sign_owner_delegation_grant(
|
|
owner_private_key: Ed25519PrivateKey,
|
|
owner_key_id: str,
|
|
*,
|
|
grant_id: str,
|
|
generation: int,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
mission_ids: tuple[str, ...] | list[str],
|
|
task_ids: tuple[str, ...] | list[str],
|
|
operations: tuple[str, ...] | list[str],
|
|
component_ids: tuple[str, ...] | list[str],
|
|
environments: tuple[str, ...] | list[str],
|
|
issued_at: int,
|
|
validity_mode: str,
|
|
expires_at: int | None = None,
|
|
max_effects: int | None = None,
|
|
destructive_ceiling: str = "NONE",
|
|
) -> dict[str, Any]:
|
|
"""Build and sign a grant with an explicitly supplied Owner private key.
|
|
|
|
``validity_mode`` is required so omitting an expiry cannot silently create an
|
|
until-revoked grant.
|
|
"""
|
|
|
|
if validity_mode == "UNTIL_REVOKED":
|
|
if expires_at is not None:
|
|
raise AuthorizationError("expires_at is forbidden for UNTIL_REVOKED")
|
|
validity: dict[str, Any] = {"mode": "UNTIL_REVOKED"}
|
|
elif validity_mode == "EXPIRES_AT":
|
|
if expires_at is None:
|
|
raise AuthorizationError("expires_at is required for EXPIRES_AT")
|
|
validity = {"mode": "EXPIRES_AT", "expires_at": expires_at}
|
|
else:
|
|
raise AuthorizationError("validity_mode is invalid")
|
|
|
|
scope_inputs = {
|
|
"mission_ids": mission_ids,
|
|
"task_ids": task_ids,
|
|
"operations": operations,
|
|
"component_ids": component_ids,
|
|
"environments": environments,
|
|
}
|
|
scope: dict[str, list[str]] = {}
|
|
for name, items in scope_inputs.items():
|
|
if type(items) not in (list, tuple):
|
|
raise AuthorizationError(f"{name} must be a list or tuple")
|
|
scope[name] = list(items)
|
|
limits = {} if max_effects is None else {"max_effects": max_effects}
|
|
payload = {
|
|
"grant_id": grant_id,
|
|
"generation": generation,
|
|
"tenant_id": tenant_id,
|
|
"subject_id": subject_id,
|
|
"scope": scope,
|
|
"issued_at": issued_at,
|
|
"validity": validity,
|
|
"limits": limits,
|
|
"destructive_ceiling": destructive_ceiling,
|
|
}
|
|
_validate_grant_payload(payload)
|
|
return _sign(GRANT_SCHEMA, payload, owner_private_key, owner_key_id)
|
|
|
|
|
|
def _public_key_bytes(key: Ed25519PublicKey) -> bytes:
|
|
return key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
|
|
|
|
def _keyring(
|
|
value: Mapping[str, Ed25519PublicKey] | None, name: str
|
|
) -> dict[str, Ed25519PublicKey]:
|
|
if value is None:
|
|
return {}
|
|
if not isinstance(value, Mapping):
|
|
raise AuthorizationError(f"{name} must be a key mapping")
|
|
result: dict[str, Ed25519PublicKey] = {}
|
|
for key_id, key in value.items():
|
|
_text(key_id, f"{name} key_id")
|
|
if not isinstance(key, Ed25519PublicKey):
|
|
raise AuthorizationError(f"{name} contains a non-Ed25519 key")
|
|
result[key_id] = key
|
|
return result
|
|
|
|
|
|
def _owner_scopes(
|
|
value: Mapping[str, Mapping[str, list[str]]] | None,
|
|
owner_public_keys: Mapping[str, Ed25519PublicKey],
|
|
) -> dict[str, dict[str, frozenset[str]]]:
|
|
if type(value) is not dict or set(value) != set(owner_public_keys):
|
|
raise AuthorizationError(
|
|
"owner_key_scopes must exactly cover owner_public_keys"
|
|
)
|
|
result: dict[str, dict[str, frozenset[str]]] = {}
|
|
for key_id, raw_scope in value.items():
|
|
if type(raw_scope) is not dict or set(raw_scope) != {
|
|
"tenant_ids",
|
|
"subject_ids",
|
|
}:
|
|
raise AuthorizationError("owner_key_scopes fields are invalid")
|
|
tenants = _scope_list(
|
|
raw_scope["tenant_ids"], f"owner_key_scopes.{key_id}.tenant_ids"
|
|
)
|
|
subjects = _scope_list(
|
|
raw_scope["subject_ids"], f"owner_key_scopes.{key_id}.subject_ids"
|
|
)
|
|
result[key_id] = {
|
|
"tenant_ids": frozenset(tenants),
|
|
"subject_ids": frozenset(subjects),
|
|
}
|
|
return result
|
|
|
|
|
|
class AuthorizationService:
|
|
"""Local authorization service backed by durable SQLite state.
|
|
|
|
The caller supplies trusted Owner keys and the machine signing identity.
|
|
Revocation is disabled unless ``control_context_verifier`` validates the
|
|
authenticated local control-service context passed to :meth:`revoke_grant`.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
db_path: str | os.PathLike[str],
|
|
owner_public_keys: Mapping[str, Ed25519PublicKey],
|
|
machine_private_key: Ed25519PrivateKey,
|
|
machine_key_id: str,
|
|
*,
|
|
owner_key_scopes: Mapping[str, Mapping[str, list[str]]],
|
|
clock: Callable[[], int | float] = time.time,
|
|
control_context_verifier: Callable[[object], bool] | None = None,
|
|
trusted_machine_public_keys: Mapping[str, Ed25519PublicKey] | None = None,
|
|
) -> None:
|
|
if not isinstance(machine_private_key, Ed25519PrivateKey):
|
|
raise AuthorizationError("machine_private_key must be Ed25519")
|
|
if not callable(clock):
|
|
raise AuthorizationError("clock must be callable")
|
|
if control_context_verifier is not None and not callable(control_context_verifier):
|
|
raise AuthorizationError("control_context_verifier must be callable")
|
|
self.db_path = Path(db_path)
|
|
self.owner_public_keys = _keyring(owner_public_keys, "owner_public_keys")
|
|
if not self.owner_public_keys:
|
|
raise AuthorizationError("at least one trusted owner key is required")
|
|
self.owner_key_scopes = _owner_scopes(
|
|
owner_key_scopes, self.owner_public_keys
|
|
)
|
|
self.machine_private_key = machine_private_key
|
|
self.machine_key_id = _text(machine_key_id, "machine_key_id")
|
|
self.machine_public_keys = _keyring(
|
|
trusted_machine_public_keys, "trusted_machine_public_keys"
|
|
)
|
|
current_public = machine_private_key.public_key()
|
|
existing = self.machine_public_keys.get(self.machine_key_id)
|
|
if existing is not None and _public_key_bytes(existing) != _public_key_bytes(current_public):
|
|
raise AuthorizationError("current machine key conflicts with trusted key mapping")
|
|
self.machine_public_keys[self.machine_key_id] = current_public
|
|
self.clock = clock
|
|
self.control_context_verifier = control_context_verifier
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._initialize()
|
|
|
|
@staticmethod
|
|
def grant_digest(envelope: Mapping[str, Any]) -> str:
|
|
return grant_digest(envelope)
|
|
|
|
def _now(self) -> int:
|
|
value = self.clock()
|
|
if type(value) is int:
|
|
now = value
|
|
elif type(value) is float and math.isfinite(value):
|
|
now = math.floor(value)
|
|
else:
|
|
raise AuthorizationError("clock must return a finite timestamp")
|
|
if now <= 0:
|
|
raise AuthorizationError("clock returned an invalid timestamp")
|
|
return now
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
connection.execute("PRAGMA busy_timeout = 30000")
|
|
return connection
|
|
|
|
def _initialize(self) -> None:
|
|
with self._connect() as connection:
|
|
connection.executescript(
|
|
"""
|
|
PRAGMA journal_mode = WAL;
|
|
CREATE TABLE IF NOT EXISTS authorization_grants (
|
|
grant_id TEXT PRIMARY KEY,
|
|
generation INTEGER NOT NULL CHECK (generation > 0),
|
|
digest TEXT NOT NULL,
|
|
envelope_json TEXT NOT NULL,
|
|
revoked INTEGER NOT NULL CHECK (revoked IN (0, 1)),
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS authorization_revocations (
|
|
grant_id TEXT NOT NULL,
|
|
generation INTEGER NOT NULL CHECK (generation > 0),
|
|
revoked_at INTEGER NOT NULL,
|
|
PRIMARY KEY (grant_id, generation)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS authorization_effect_reservations (
|
|
grant_id TEXT NOT NULL,
|
|
grant_generation INTEGER NOT NULL CHECK (grant_generation > 0),
|
|
effect_id TEXT NOT NULL,
|
|
reserved_at INTEGER NOT NULL,
|
|
PRIMARY KEY (grant_id, grant_generation, effect_id)
|
|
);
|
|
"""
|
|
)
|
|
|
|
def register_grant(self, signed_envelope: dict[str, Any]) -> dict[str, Any]:
|
|
envelope = _json_copy(
|
|
_validate_grant_envelope(
|
|
signed_envelope, self.owner_public_keys, self.owner_key_scopes
|
|
)
|
|
)
|
|
payload = envelope["payload"]
|
|
now = self._now()
|
|
self._check_grant_time(payload, now)
|
|
digest = grant_digest(envelope)
|
|
encoded = _canonical_json(envelope).decode("utf-8")
|
|
with self._connect() as connection:
|
|
try:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
row = connection.execute(
|
|
"SELECT generation, digest, revoked FROM authorization_grants WHERE grant_id = ?",
|
|
(payload["grant_id"],),
|
|
).fetchone()
|
|
if row is None:
|
|
connection.execute(
|
|
"INSERT INTO authorization_grants "
|
|
"(grant_id, generation, digest, envelope_json, revoked, updated_at) "
|
|
"VALUES (?, ?, ?, ?, 0, ?)",
|
|
(
|
|
payload["grant_id"],
|
|
payload["generation"],
|
|
digest,
|
|
encoded,
|
|
now,
|
|
),
|
|
)
|
|
elif payload["generation"] < row["generation"]:
|
|
raise AuthorizationError("grant generation is stale")
|
|
elif payload["generation"] == row["generation"]:
|
|
if digest != row["digest"]:
|
|
raise AuthorizationError("grant generation conflicts with durable state")
|
|
if row["revoked"]:
|
|
raise AuthorizationError("grant is revoked")
|
|
else:
|
|
connection.execute(
|
|
"UPDATE authorization_grants SET generation = ?, digest = ?, "
|
|
"envelope_json = ?, revoked = 0, updated_at = ? WHERE grant_id = ?",
|
|
(
|
|
payload["generation"],
|
|
digest,
|
|
encoded,
|
|
now,
|
|
payload["grant_id"],
|
|
),
|
|
)
|
|
connection.commit()
|
|
except Exception:
|
|
connection.rollback()
|
|
raise
|
|
return _json_copy(envelope)
|
|
|
|
@staticmethod
|
|
def _check_grant_time(payload: dict[str, Any], now: int) -> None:
|
|
if payload["issued_at"] > now:
|
|
raise AuthorizationError("grant is not yet valid")
|
|
validity = payload["validity"]
|
|
if validity["mode"] == "EXPIRES_AT" and now >= validity["expires_at"]:
|
|
raise AuthorizationError("grant has expired")
|
|
|
|
def _load_active_grant(
|
|
self, connection: sqlite3.Connection, grant_id: str, now: int
|
|
) -> tuple[dict[str, Any], str, int]:
|
|
row = connection.execute(
|
|
"SELECT generation, digest, envelope_json, revoked "
|
|
"FROM authorization_grants WHERE grant_id = ?",
|
|
(grant_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise AuthorizationError("grant is not registered")
|
|
if row["revoked"]:
|
|
raise AuthorizationError("grant is revoked")
|
|
try:
|
|
decoded = json.loads(row["envelope_json"])
|
|
except (TypeError, json.JSONDecodeError) as exc:
|
|
raise AuthorizationError("durable grant state is invalid") from exc
|
|
envelope = _validate_grant_envelope(
|
|
decoded, self.owner_public_keys, self.owner_key_scopes
|
|
)
|
|
payload = envelope["payload"]
|
|
digest = grant_digest(envelope)
|
|
if (
|
|
payload["grant_id"] != grant_id
|
|
or payload["generation"] != row["generation"]
|
|
or digest != row["digest"]
|
|
):
|
|
raise AuthorizationError("durable grant state failed integrity validation")
|
|
self._check_grant_time(payload, now)
|
|
return envelope, digest, row["generation"]
|
|
|
|
@staticmethod
|
|
def _bindings(
|
|
*,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
mission_id: str,
|
|
task_id: str,
|
|
operation: str,
|
|
component_id: str,
|
|
environment: str,
|
|
) -> dict[str, str]:
|
|
values = {
|
|
"tenant_id": tenant_id,
|
|
"subject_id": subject_id,
|
|
"mission_id": mission_id,
|
|
"task_id": task_id,
|
|
"operation": operation,
|
|
"component_id": component_id,
|
|
"environment": environment,
|
|
}
|
|
for field, value in values.items():
|
|
_text(value, field)
|
|
return values
|
|
|
|
@staticmethod
|
|
def _check_narrowing(grant: dict[str, Any], bindings: dict[str, str]) -> None:
|
|
payload = grant["payload"]
|
|
if bindings["tenant_id"] != payload["tenant_id"] or bindings["subject_id"] != payload["subject_id"]:
|
|
raise AuthorizationError("requested identity is outside grant scope")
|
|
scope_names = {
|
|
"mission_id": "mission_ids",
|
|
"task_id": "task_ids",
|
|
"operation": "operations",
|
|
"component_id": "component_ids",
|
|
"environment": "environments",
|
|
}
|
|
for binding_name, scope_name in scope_names.items():
|
|
allowed = payload["scope"][scope_name]
|
|
if allowed != ["*"] and bindings[binding_name] not in allowed:
|
|
raise AuthorizationError("requested value is outside grant scope")
|
|
|
|
def issue_principal(
|
|
self,
|
|
grant_id: str,
|
|
*,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
mission_id: str,
|
|
task_id: str,
|
|
operation: str,
|
|
component_id: str,
|
|
environment: str,
|
|
ttl_seconds: int,
|
|
) -> dict[str, Any]:
|
|
_text(grant_id, "grant_id")
|
|
if type(ttl_seconds) is not int or not (1 <= ttl_seconds <= MAX_PRINCIPAL_TTL_SECONDS):
|
|
raise AuthorizationError("ttl_seconds is invalid")
|
|
bindings = self._bindings(
|
|
tenant_id=tenant_id,
|
|
subject_id=subject_id,
|
|
mission_id=mission_id,
|
|
task_id=task_id,
|
|
operation=operation,
|
|
component_id=component_id,
|
|
environment=environment,
|
|
)
|
|
now = self._now()
|
|
with self._connect() as connection:
|
|
root, digest, generation = self._load_active_grant(connection, grant_id, now)
|
|
self._check_narrowing(root, bindings)
|
|
expires_at = now + ttl_seconds
|
|
validity = root["payload"]["validity"]
|
|
if validity["mode"] == "EXPIRES_AT":
|
|
expires_at = min(expires_at, validity["expires_at"])
|
|
if expires_at <= now:
|
|
raise AuthorizationError("grant has expired")
|
|
root_binding = {
|
|
"root_grant_id": grant_id,
|
|
"root_grant_digest": digest,
|
|
"root_grant_generation": generation,
|
|
}
|
|
epoch_payload = {
|
|
"epoch_id": str(uuid.uuid4()),
|
|
**root_binding,
|
|
**bindings,
|
|
"issued_at": now,
|
|
"expires_at": expires_at,
|
|
}
|
|
epoch = _sign(
|
|
EPOCH_SCHEMA,
|
|
epoch_payload,
|
|
self.machine_private_key,
|
|
self.machine_key_id,
|
|
)
|
|
principal_payload = {
|
|
"credential_id": str(uuid.uuid4()),
|
|
"nonce": secrets.token_hex(24),
|
|
"parent_epoch_digest": hashlib.sha256(_canonical_json(epoch)).hexdigest(),
|
|
**root_binding,
|
|
**bindings,
|
|
"issued_at": now,
|
|
"expires_at": expires_at,
|
|
}
|
|
return _sign(
|
|
PRINCIPAL_SCHEMA,
|
|
principal_payload,
|
|
self.machine_private_key,
|
|
self.machine_key_id,
|
|
extra={"operational_epoch": epoch},
|
|
)
|
|
|
|
@staticmethod
|
|
def _validate_chain_payload(
|
|
payload: Any, *, principal: bool
|
|
) -> dict[str, Any]:
|
|
value = _plain_object(
|
|
payload, "principal payload" if principal else "operational epoch payload"
|
|
)
|
|
unique = {"credential_id", "nonce", "parent_epoch_digest"} if principal else {"epoch_id"}
|
|
expected = unique | set(_ROOT_FIELDS) | set(_BINDING_FIELDS) | {
|
|
"issued_at",
|
|
"expires_at",
|
|
}
|
|
_exact_fields(
|
|
value,
|
|
expected,
|
|
"principal payload" if principal else "operational epoch payload",
|
|
)
|
|
for field in _BINDING_FIELDS:
|
|
_text(value[field], field)
|
|
_text(value["root_grant_id"], "root_grant_id")
|
|
if type(value["root_grant_digest"]) is not str or not _DIGEST_RE.fullmatch(
|
|
value["root_grant_digest"]
|
|
):
|
|
raise AuthorizationError("root_grant_digest is invalid")
|
|
_positive_int(value["root_grant_generation"], "root_grant_generation")
|
|
issued = _timestamp(value["issued_at"], "issued_at")
|
|
expires = _timestamp(value["expires_at"], "expires_at")
|
|
if expires <= issued or expires - issued > MAX_PRINCIPAL_TTL_SECONDS:
|
|
raise AuthorizationError("credential validity interval is invalid")
|
|
if principal:
|
|
_text(value["credential_id"], "credential_id")
|
|
_text(value["nonce"], "nonce")
|
|
if type(value["parent_epoch_digest"]) is not str or not _DIGEST_RE.fullmatch(
|
|
value["parent_epoch_digest"]
|
|
):
|
|
raise AuthorizationError("parent_epoch_digest is invalid")
|
|
else:
|
|
_text(value["epoch_id"], "epoch_id")
|
|
return value
|
|
|
|
def _verify_epoch(self, epoch: Any, now: int) -> dict[str, Any]:
|
|
value = _plain_object(epoch, "operational epoch")
|
|
_exact_fields(value, {"schema", "payload", "signing"}, "operational epoch")
|
|
if value["schema"] != EPOCH_SCHEMA:
|
|
raise AuthorizationError("operational epoch schema is invalid")
|
|
payload = self._validate_chain_payload(value["payload"], principal=False)
|
|
_verify_signature(value, self.machine_public_keys, trust_name="machine")
|
|
if payload["issued_at"] > now:
|
|
raise AuthorizationError("operational epoch is not yet valid")
|
|
if now >= payload["expires_at"]:
|
|
raise AuthorizationError("operational epoch has expired")
|
|
return value
|
|
|
|
def _verify_principal(self, principal: Any, now: int) -> dict[str, Any]:
|
|
value = _plain_object(principal, "task principal")
|
|
_exact_fields(
|
|
value,
|
|
{"schema", "payload", "operational_epoch", "signing"},
|
|
"task principal",
|
|
)
|
|
if value["schema"] != PRINCIPAL_SCHEMA:
|
|
raise AuthorizationError("task principal schema is invalid")
|
|
epoch = self._verify_epoch(value["operational_epoch"], now)
|
|
payload = self._validate_chain_payload(value["payload"], principal=True)
|
|
_verify_signature(value, self.machine_public_keys, trust_name="machine")
|
|
if payload["issued_at"] > now:
|
|
raise AuthorizationError("task principal is not yet valid")
|
|
if now >= payload["expires_at"]:
|
|
raise AuthorizationError("task principal has expired")
|
|
expected_parent = hashlib.sha256(_canonical_json(epoch)).hexdigest()
|
|
if payload["parent_epoch_digest"] != expected_parent:
|
|
raise AuthorizationError("task principal parent digest is invalid")
|
|
epoch_payload = epoch["payload"]
|
|
for field in (*_ROOT_FIELDS, *_BINDING_FIELDS, "issued_at", "expires_at"):
|
|
if payload[field] != epoch_payload[field]:
|
|
raise AuthorizationError("task principal does not match parent epoch")
|
|
return value
|
|
|
|
def _authorize_with_connection(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
principal: dict[str, Any],
|
|
bindings: dict[str, str],
|
|
now: int,
|
|
) -> dict[str, Any]:
|
|
credential = self._verify_principal(principal, now)
|
|
payload = credential["payload"]
|
|
for field, expected in bindings.items():
|
|
if payload[field] != expected:
|
|
raise AuthorizationError("task principal scope does not match request")
|
|
root, digest, generation = self._load_active_grant(
|
|
connection, payload["root_grant_id"], now
|
|
)
|
|
if payload["root_grant_generation"] != generation:
|
|
raise AuthorizationError("task principal root generation is stale")
|
|
if payload["root_grant_digest"] != digest:
|
|
raise AuthorizationError("task principal root digest is stale")
|
|
self._check_narrowing(root, bindings)
|
|
return root
|
|
|
|
def authorize(
|
|
self,
|
|
principal: dict[str, Any],
|
|
*,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
mission_id: str,
|
|
task_id: str,
|
|
operation: str,
|
|
component_id: str,
|
|
environment: str,
|
|
) -> dict[str, Any]:
|
|
bindings = self._bindings(
|
|
tenant_id=tenant_id,
|
|
subject_id=subject_id,
|
|
mission_id=mission_id,
|
|
task_id=task_id,
|
|
operation=operation,
|
|
component_id=component_id,
|
|
environment=environment,
|
|
)
|
|
now = self._now()
|
|
with self._connect() as connection:
|
|
root = self._authorize_with_connection(connection, principal, bindings, now)
|
|
return _json_copy(root)
|
|
|
|
def assert_effect_bounds(
|
|
self,
|
|
principal: dict[str, Any],
|
|
operation_definition: Any,
|
|
*,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
mission_id: str,
|
|
task_id: str,
|
|
operation: str,
|
|
component_id: str,
|
|
environment: str,
|
|
) -> dict[str, Any]:
|
|
"""Authorize a trusted operation definition against the signed ceiling.
|
|
|
|
This gate limits effect class; it does not replace the separately
|
|
required Job authorization, intent commit, claim lease, receiver fence,
|
|
readback, or reconciliation checks for a mutating operation.
|
|
"""
|
|
|
|
from .operations import OperationDefinition
|
|
|
|
if not isinstance(operation_definition, OperationDefinition):
|
|
raise AuthorizationError("trusted OperationDefinition is required")
|
|
if operation_definition.operation_id != operation:
|
|
raise AuthorizationError("operation definition does not match authorization scope")
|
|
root = self.authorize(
|
|
principal,
|
|
tenant_id=tenant_id,
|
|
subject_id=subject_id,
|
|
mission_id=mission_id,
|
|
task_id=task_id,
|
|
operation=operation,
|
|
component_id=component_id,
|
|
environment=environment,
|
|
)
|
|
ceiling = root["payload"]["destructive_ceiling"]
|
|
effect_class = operation_definition.effect_class
|
|
if ceiling == "NONE" and effect_class != "READ_ONLY":
|
|
raise AuthorizationError("operation exceeds grant destructive ceiling")
|
|
if ceiling == "REVERSIBLE" and effect_class == "IRREVERSIBLE":
|
|
raise AuthorizationError("irreversible operation exceeds grant destructive ceiling")
|
|
if ceiling == "REVERSIBLE" and effect_class != "READ_ONLY":
|
|
if operation_definition.rollback.get("mode") not in {"OPERATION", "COMPENSATE"}:
|
|
raise AuthorizationError("reversible ceiling requires a rollback policy")
|
|
if (
|
|
operation_definition.readback.get("mode") not in {"STATE", "EXACT"}
|
|
or operation_definition.readback.get("required") is not True
|
|
or operation_definition.reconcile.get("mode") == "NONE"
|
|
):
|
|
raise AuthorizationError(
|
|
"reversible ceiling requires readback and reconciliation"
|
|
)
|
|
return root
|
|
|
|
def reserve_effect(
|
|
self,
|
|
grant_id: str,
|
|
effect_id: str,
|
|
principal: dict[str, Any],
|
|
*,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
mission_id: str,
|
|
task_id: str,
|
|
operation: str,
|
|
component_id: str,
|
|
environment: str,
|
|
) -> bool:
|
|
"""Atomically reserve one stable effect identifier against a grant budget.
|
|
|
|
Returns ``False`` for an already-reserved identifier, so intent retries do
|
|
not consume the bounded grant twice. Grants without ``max_effects`` leave
|
|
mission-level budget enforcement to their caller.
|
|
"""
|
|
|
|
_text(grant_id, "grant_id")
|
|
_text(effect_id, "effect_id")
|
|
bindings = self._bindings(
|
|
tenant_id=tenant_id,
|
|
subject_id=subject_id,
|
|
mission_id=mission_id,
|
|
task_id=task_id,
|
|
operation=operation,
|
|
component_id=component_id,
|
|
environment=environment,
|
|
)
|
|
now = self._now()
|
|
with self._connect() as connection:
|
|
try:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
root = self._authorize_with_connection(connection, principal, bindings, now)
|
|
payload = principal["payload"]
|
|
if payload["root_grant_id"] != grant_id:
|
|
raise AuthorizationError("effect reservation grant does not match principal")
|
|
generation = payload["root_grant_generation"]
|
|
existing = connection.execute(
|
|
"SELECT 1 FROM authorization_effect_reservations "
|
|
"WHERE grant_id = ? AND grant_generation = ? AND effect_id = ?",
|
|
(grant_id, generation, effect_id),
|
|
).fetchone()
|
|
if existing is not None:
|
|
connection.commit()
|
|
return False
|
|
max_effects = root["payload"]["limits"].get("max_effects")
|
|
if max_effects is not None:
|
|
count = connection.execute(
|
|
"SELECT COUNT(*) FROM authorization_effect_reservations "
|
|
"WHERE grant_id = ? AND grant_generation = ?",
|
|
(grant_id, generation),
|
|
).fetchone()[0]
|
|
if count >= max_effects:
|
|
raise AuthorizationError("grant effect budget is exhausted")
|
|
connection.execute(
|
|
"INSERT INTO authorization_effect_reservations "
|
|
"(grant_id, grant_generation, effect_id, reserved_at) VALUES (?, ?, ?, ?)",
|
|
(grant_id, generation, effect_id, now),
|
|
)
|
|
connection.commit()
|
|
return True
|
|
except Exception:
|
|
connection.rollback()
|
|
raise
|
|
|
|
def revoke_grant(self, grant_id: str, *, control_context: object) -> int:
|
|
"""Revoke a grant from an authenticated local control-service context."""
|
|
|
|
_text(grant_id, "grant_id")
|
|
verifier = self.control_context_verifier
|
|
trusted = False
|
|
if verifier is not None:
|
|
try:
|
|
trusted = verifier(control_context) is True
|
|
except Exception:
|
|
trusted = False
|
|
if not trusted:
|
|
raise AuthorizationError("trusted control context is required for revocation")
|
|
now = self._now()
|
|
with self._connect() as connection:
|
|
try:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
row = connection.execute(
|
|
"SELECT generation, revoked FROM authorization_grants WHERE grant_id = ?",
|
|
(grant_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise AuthorizationError("grant is not registered")
|
|
if row["revoked"]:
|
|
connection.commit()
|
|
return row["generation"]
|
|
generation = row["generation"] + 1
|
|
connection.execute(
|
|
"UPDATE authorization_grants SET generation = ?, revoked = 1, "
|
|
"updated_at = ? WHERE grant_id = ?",
|
|
(generation, now, grant_id),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO authorization_revocations "
|
|
"(grant_id, generation, revoked_at) VALUES (?, ?, ?)",
|
|
(grant_id, generation, now),
|
|
)
|
|
connection.commit()
|
|
return generation
|
|
except Exception:
|
|
connection.rollback()
|
|
raise
|
|
|
|
|
|
__all__ = [
|
|
"AuthorizationError",
|
|
"AuthorizationService",
|
|
"DESTRUCTIVE_CEILINGS",
|
|
"EPOCH_SCHEMA",
|
|
"GRANT_SCHEMA",
|
|
"MAX_PRINCIPAL_TTL_SECONDS",
|
|
"PRINCIPAL_SCHEMA",
|
|
"grant_digest",
|
|
"sign_owner_delegation_grant",
|
|
]
|