APPROVAL-WP-0007: record the verified revoker and park the inactive plans
Schema v9 stores who revoked an approval and an optional bounded reason. The bounded-execution carrier example is proven in process. APPROVAL-WP-0005, 0006, and 0007 are blocked because they are not current work. Task statuses stay as they were, apart from T03 and T04, which are done in source. Assistant: grok Assistant-Session: 01a1b5fd-0c33-7ba1-b383-53937d525595
This commit is contained in:
parent
f41a9ca82c
commit
4aa0b75449
17 changed files with 599 additions and 36 deletions
|
|
@ -161,8 +161,13 @@ class App:
|
|||
)
|
||||
return 200, obj.as_dict()
|
||||
if rest == ["revoke"] and method == "POST":
|
||||
self.identity(environ, "approval:revoke")
|
||||
return 200, self.engine.revoke(approval_id).as_dict()
|
||||
identity = self.identity(environ, "approval:revoke")
|
||||
data = _read_json(environ)
|
||||
if set(data) - {"reason"}:
|
||||
raise Unprocessable("revoke accepts only an optional reason")
|
||||
return 200, self.engine.revoke(
|
||||
approval_id, revoker=identity, reason=data.get("reason")
|
||||
).as_dict()
|
||||
if rest == ["supersede"] and method == "POST":
|
||||
self.identity(environ, "approval:supersede")
|
||||
data = _read_json(environ)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
|||
# class and says nothing about the quiet one — here that is `revocation`, which
|
||||
# is the class whose silence actually matters.
|
||||
HEARTBEAT_CLASSES = ("issuance", "use", "supersession", "revocation")
|
||||
LATEST_SCHEMA_VERSION = 8
|
||||
LATEST_SCHEMA_VERSION = 9
|
||||
# APPROVAL-WP-0007-T03. A revocation reason is optional evidence, one line,
|
||||
# bounded so a caller cannot put an unbounded blob in the local outbox.
|
||||
REVOCATION_REASON_MAX = 200
|
||||
# APPROVAL-WP-0007-T01: authenticator assurance levels, weakest first. A level
|
||||
# outside this list is unknown, and an unknown level never meets a floor.
|
||||
ASSURANCE_LEVELS = ("aal1", "aal2", "aal3")
|
||||
|
|
@ -108,6 +111,12 @@ CREATE TABLE IF NOT EXISTS approvals (
|
|||
human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)),
|
||||
required_assurance TEXT CHECK (required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3')),
|
||||
actor_consumes INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1)),
|
||||
revoked_by_subject TEXT,
|
||||
revoked_by_principal_type TEXT CHECK (
|
||||
revoked_by_principal_type IS NULL OR revoked_by_principal_type IN ('human', 'service', 'agent')
|
||||
),
|
||||
revoked_by_assurance TEXT,
|
||||
revocation_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -167,6 +176,10 @@ class Approval:
|
|||
human_control: bool
|
||||
required_assurance: str | None
|
||||
actor_consumes: bool
|
||||
revoked_by_subject: str | None
|
||||
revoked_by_principal_type: str | None
|
||||
revoked_by_assurance: str | None
|
||||
revocation_reason: str | None
|
||||
actor: str
|
||||
principal: str
|
||||
action: str
|
||||
|
|
@ -208,6 +221,17 @@ class Approval:
|
|||
if self.actor_consumes:
|
||||
# APPROVAL-WP-0007-T02, same rule: stated only when declared.
|
||||
result["binding"]["actor_consumes"] = True
|
||||
if self.revoked_by_subject:
|
||||
# APPROVAL-WP-0007-T03. Absent when no verified revoker was
|
||||
# recorded, including legacy rows and internal revoke calls.
|
||||
revoked_by = {
|
||||
"subject": self.revoked_by_subject,
|
||||
"principal_type": self.revoked_by_principal_type,
|
||||
"assurance": json.loads(self.revoked_by_assurance),
|
||||
}
|
||||
if self.revocation_reason:
|
||||
revoked_by["reason"] = self.revocation_reason
|
||||
result["revoked_by"] = revoked_by
|
||||
if self.consumed_digest:
|
||||
result["consumption"] = {
|
||||
"request_digest": self.consumed_digest,
|
||||
|
|
@ -317,6 +341,18 @@ class Engine:
|
|||
"ALTER TABLE approvals ADD COLUMN actor_consumes "
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1))"
|
||||
)
|
||||
if "revoked_by_subject" not in approval_columns:
|
||||
# v9, APPROVAL-WP-0007-T03. Legacy revocations stay NULL: the
|
||||
# revoker was not verified into the row, and inventing one
|
||||
# would manufacture evidence.
|
||||
conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_subject TEXT")
|
||||
conn.execute(
|
||||
"ALTER TABLE approvals ADD COLUMN revoked_by_principal_type TEXT CHECK ("
|
||||
"revoked_by_principal_type IS NULL OR "
|
||||
"revoked_by_principal_type IN ('human', 'service', 'agent'))"
|
||||
)
|
||||
conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_assurance TEXT")
|
||||
conn.execute("ALTER TABLE approvals ADD COLUMN revocation_reason TEXT")
|
||||
entry_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
|
||||
}
|
||||
|
|
@ -561,6 +597,10 @@ class Engine:
|
|||
human_control=bool(row["human_control"]),
|
||||
required_assurance=row["required_assurance"],
|
||||
actor_consumes=bool(row["actor_consumes"]),
|
||||
revoked_by_subject=row["revoked_by_subject"],
|
||||
revoked_by_principal_type=row["revoked_by_principal_type"],
|
||||
revoked_by_assurance=row["revoked_by_assurance"],
|
||||
revocation_reason=row["revocation_reason"],
|
||||
actor=row["actor"],
|
||||
principal=row["principal"],
|
||||
action=row["action"],
|
||||
|
|
@ -685,8 +725,58 @@ class Engine:
|
|||
raise
|
||||
return self.get(approval_id)
|
||||
|
||||
def revoke(self, approval_id: str) -> Approval:
|
||||
return self._terminal(approval_id, "revoked", "revocation")
|
||||
def revoke(
|
||||
self,
|
||||
approval_id: str,
|
||||
*,
|
||||
revoker: Identity | None = None,
|
||||
reason: str | None = None,
|
||||
) -> Approval:
|
||||
"""Terminal revoke. CAS and the closed machine are unchanged.
|
||||
|
||||
A verified revoker is recorded when the HTTP boundary supplies one.
|
||||
The revoker is not required to be the bound principal: revocation
|
||||
must stay possible without the holder. An internal call with no
|
||||
revoker still revokes and leaves the evidence fields empty.
|
||||
"""
|
||||
return self._terminal(
|
||||
approval_id,
|
||||
"revoked",
|
||||
"revocation",
|
||||
revoked_by=self._revoker_evidence(revoker, reason),
|
||||
)
|
||||
|
||||
def _revoker_evidence(
|
||||
self, revoker: Identity | None, reason: str | None
|
||||
) -> dict[str, str] | None:
|
||||
if reason is not None and revoker is None:
|
||||
raise Unprocessable("revocation reason requires a verified revoker")
|
||||
if reason is not None:
|
||||
if not isinstance(reason, str) or reason != reason.strip() or not reason:
|
||||
raise Unprocessable(
|
||||
"revocation reason must be a non-empty string without surrounding space"
|
||||
)
|
||||
if len(reason) > REVOCATION_REASON_MAX or any(ord(ch) < 32 for ch in reason):
|
||||
raise Unprocessable(
|
||||
f"revocation reason must be a single line of at most {REVOCATION_REASON_MAX} characters"
|
||||
)
|
||||
if revoker is None:
|
||||
return None
|
||||
if revoker.principal_type not in {"human", "service", "agent"} or not revoker.subject:
|
||||
raise Unprocessable("verified revoker subject and principal_type are required")
|
||||
if not isinstance(revoker.assurance, dict) or not revoker.assurance:
|
||||
raise Unprocessable("verified revoker assurance is required")
|
||||
assurance = json.dumps(revoker.assurance, sort_keys=True)
|
||||
if len(assurance) > 1024:
|
||||
raise Unprocessable("verified revoker assurance is too large")
|
||||
evidence = {
|
||||
"subject": revoker.subject,
|
||||
"principal_type": revoker.principal_type,
|
||||
"assurance": assurance,
|
||||
}
|
||||
if reason is not None:
|
||||
evidence["reason"] = reason
|
||||
return evidence
|
||||
|
||||
def supersede(
|
||||
self,
|
||||
|
|
@ -809,7 +899,14 @@ class Engine:
|
|||
"successor_created": created_successor,
|
||||
}
|
||||
|
||||
def _terminal(self, approval_id: str, status: str, event_class: str) -> Approval:
|
||||
def _terminal(
|
||||
self,
|
||||
approval_id: str,
|
||||
status: str,
|
||||
event_class: str,
|
||||
*,
|
||||
revoked_by: dict[str, str] | None = None,
|
||||
) -> Approval:
|
||||
conn = self._conn()
|
||||
now = iso(self.now())
|
||||
try:
|
||||
|
|
@ -818,10 +915,27 @@ class Engine:
|
|||
if row is None:
|
||||
conn.rollback()
|
||||
raise NotFound(approval_id)
|
||||
subject = principal_type = assurance = reason = None
|
||||
extra: dict[str, Any] = {"binding_digest": row["binding_digest"]}
|
||||
if revoked_by is not None:
|
||||
subject = revoked_by["subject"]
|
||||
principal_type = revoked_by["principal_type"]
|
||||
assurance = revoked_by["assurance"]
|
||||
reason = revoked_by.get("reason")
|
||||
recorded = {
|
||||
"subject": subject,
|
||||
"principal_type": principal_type,
|
||||
"assurance": json.loads(assurance),
|
||||
}
|
||||
if reason is not None:
|
||||
recorded["reason"] = reason
|
||||
extra["revoked_by"] = recorded
|
||||
cur = conn.execute(
|
||||
"UPDATE approvals SET status=?, updated_at=? "
|
||||
"UPDATE approvals SET status=?, updated_at=?, "
|
||||
"revoked_by_subject=?, revoked_by_principal_type=?, "
|
||||
"revoked_by_assurance=?, revocation_reason=? "
|
||||
"WHERE id=? AND status IN ('requested','approved')",
|
||||
(status, now, approval_id),
|
||||
(status, now, subject, principal_type, assurance, reason, approval_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
|
|
@ -831,7 +945,7 @@ class Engine:
|
|||
event_class,
|
||||
approval_id,
|
||||
actor=row["actor"],
|
||||
extra={"binding_digest": row["binding_digest"]},
|
||||
extra=extra,
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue