ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Case study: Banking / transactions.
|
||||
|
||||
Architectural pattern demonstrated: **cross-object balance invariant**.
|
||||
A transaction's validity depends on the sender account's current balance --
|
||||
the validator must read related state (the sender account) and compare to the
|
||||
proposed change. This pattern doesn't fit single-table CHECK constraints; it
|
||||
requires a runtime validator that reads cross-object state at write time.
|
||||
|
||||
Object types: account, transaction, audit_log
|
||||
Key invariants:
|
||||
- sender_account.balance >= amount before transaction commits
|
||||
- account ownership: only the account's owner can initiate transfers
|
||||
- transactions are immutable after creation (no UPDATE)
|
||||
- balance updates are reactions, not direct writes (single source of truth)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, ReactionDeclaration,
|
||||
Relationship, RelationshipAction,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
|
||||
def validate_transaction_balance(proposed, existing, accessor, store):
|
||||
"""Sender account must have sufficient balance for the transfer."""
|
||||
if existing is not None:
|
||||
return "Transactions are immutable; cannot UPDATE"
|
||||
sender_id = proposed.content.get("sender_account_id")
|
||||
amount = proposed.content.get("amount", 0)
|
||||
if sender_id is None or amount is None:
|
||||
return "sender_account_id and amount are required"
|
||||
if amount <= 0:
|
||||
return f"Transfer amount must be positive, got {amount}"
|
||||
sender = store.raw_read(sender_id)
|
||||
if sender is None:
|
||||
return f"Sender account {sender_id} not found"
|
||||
if sender.type_name != "account":
|
||||
return f"sender_account_id must reference an account, not {sender.type_name}"
|
||||
if sender.content.get("balance", 0) < amount:
|
||||
return (f"Insufficient balance: account has "
|
||||
f"{sender.content.get('balance', 0)}, transfer requires {amount}")
|
||||
return True
|
||||
|
||||
|
||||
def validate_transaction_recipient_exists(proposed, existing, accessor, store):
|
||||
"""Recipient account must exist and accept transfers."""
|
||||
if existing is not None:
|
||||
return True
|
||||
recipient_id = proposed.content.get("recipient_account_id")
|
||||
if not recipient_id:
|
||||
return "recipient_account_id is required"
|
||||
recipient = store.raw_read(recipient_id)
|
||||
if recipient is None or recipient.type_name != "account":
|
||||
return f"Recipient account {recipient_id} not found"
|
||||
if recipient.content.get("status") == "frozen":
|
||||
return "Recipient account is frozen"
|
||||
return True
|
||||
|
||||
|
||||
def apply_transaction(event, store):
|
||||
"""Reaction: when a transaction commits, debit sender and credit recipient.
|
||||
The handler did not have to encode this -- the schema declares it."""
|
||||
sys_ctx = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
sender_id = event["object_content"]["sender_account_id"]
|
||||
recipient_id = event["object_content"]["recipient_account_id"]
|
||||
amount = event["object_content"]["amount"]
|
||||
|
||||
sender = store.raw_read(sender_id)
|
||||
if sender is not None:
|
||||
store.update(sender_id,
|
||||
{"balance": sender.content.get("balance", 0) - amount},
|
||||
sys_ctx, _reaction_depth=event["depth"])
|
||||
recipient = store.raw_read(recipient_id)
|
||||
if recipient is not None:
|
||||
store.update(recipient_id,
|
||||
{"balance": recipient.content.get("balance", 0) + amount},
|
||||
sys_ctx, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_banking_types(store: ObjectStore) -> None:
|
||||
store.register_reaction_handler("apply_transaction", apply_transaction)
|
||||
|
||||
# Account: owner-only management; balance is updated only by reactions.
|
||||
store.register_type(ObjectType(
|
||||
name="account",
|
||||
fields={"holder_name": "str", "balance": "int", "status": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "auditor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
# Balance writes happen via the apply_transaction reaction (system role)
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Transaction: immutable after create; validators run cross-object checks.
|
||||
store.register_type(ObjectType(
|
||||
name="transaction",
|
||||
fields={"sender_account_id": "str", "recipient_account_id": "str",
|
||||
"amount": "int", "subject": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "auditor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}), # any authenticated user
|
||||
# No WRITE / UPDATE / DELETE rules: transactions are immutable.
|
||||
],
|
||||
validators=[validate_transaction_balance,
|
||||
validate_transaction_recipient_exists],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="apply_transaction"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Case study: E-commerce orders.
|
||||
|
||||
Architectural pattern demonstrated: **compound state machine + reactions**.
|
||||
An order moves through cart -> placed -> paid -> shipped -> delivered, with
|
||||
refund inverting the state from delivered back to refunded. Each transition
|
||||
has invariants (placed orders cannot be modified; only paid orders can ship).
|
||||
The state-machine validator + reactions handle the bookkeeping; the handler
|
||||
just describes the user intent (place / pay / ship / refund).
|
||||
|
||||
Object types: customer, product, order, order_item, audit_log
|
||||
Key invariants:
|
||||
- order status follows: cart -> placed -> paid -> shipped -> delivered, or any -> cancelled, delivered -> refunded
|
||||
- only the order owner can place / cancel; only admin can ship; only paid orders can ship
|
||||
- inventory decremented on payment (via reaction)
|
||||
- refund inverts inventory (via reaction)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, ReactionDeclaration,
|
||||
Relationship, RelationshipAction,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
|
||||
ORDER_TRANSITIONS = {
|
||||
None: ["cart"],
|
||||
"cart": ["placed", "cancelled"],
|
||||
"placed": ["paid", "cancelled"],
|
||||
"paid": ["shipped", "refunded"], # paid can be refunded
|
||||
"shipped": ["delivered"],
|
||||
"delivered": ["refunded"], # delivered can be refunded
|
||||
"cancelled": [],
|
||||
"refunded": [],
|
||||
}
|
||||
|
||||
|
||||
def validate_order_status(proposed, existing, accessor, store):
|
||||
new_status = proposed.content.get("status")
|
||||
if existing is None:
|
||||
if new_status not in ("cart", None):
|
||||
return f"New orders must start as 'cart', got {new_status!r}"
|
||||
return True
|
||||
old_status = existing.content.get("status")
|
||||
if new_status and new_status != old_status:
|
||||
valid = ORDER_TRANSITIONS.get(old_status, [])
|
||||
if new_status not in valid:
|
||||
return f"Invalid order transition {old_status} -> {new_status}; valid: {valid}"
|
||||
return True
|
||||
|
||||
|
||||
def validate_only_paid_can_ship(proposed, existing, accessor, store):
|
||||
if existing is None:
|
||||
return True
|
||||
if proposed.content.get("status") == "shipped":
|
||||
if existing.content.get("status") != "paid":
|
||||
return f"Only paid orders can ship; current status is {existing.content.get('status')}"
|
||||
return True
|
||||
|
||||
|
||||
def adjust_inventory_on_state_change(event, store):
|
||||
"""Reaction: decrement inventory on 'paid', increment on 'refunded'."""
|
||||
sys_ctx = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
new_status = event["object_content"].get("status")
|
||||
if new_status not in ("paid", "refunded"):
|
||||
return
|
||||
direction = -1 if new_status == "paid" else +1
|
||||
# Find order_items for this order, adjust each product's stock
|
||||
order_id = event["object_id"]
|
||||
items = store.raw_query("order_item")
|
||||
for item in items:
|
||||
if item.content.get("order_id") != order_id:
|
||||
continue
|
||||
product_id = item.content.get("product_id")
|
||||
qty = item.content.get("quantity", 0)
|
||||
product = store.raw_read(product_id)
|
||||
if product is None:
|
||||
continue
|
||||
current = product.content.get("stock", 0)
|
||||
store.update(product_id, {"stock": current + direction * qty},
|
||||
sys_ctx, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_ecommerce_types(store: ObjectStore) -> None:
|
||||
store.register_reaction_handler("adjust_inventory_on_state_change",
|
||||
adjust_inventory_on_state_change)
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="customer",
|
||||
fields={"name": "str", "email": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="product",
|
||||
fields={"name": "str", "price": "int", "stock": "int"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {}), # public catalog
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
# Stock writes only via reactions (system role)
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="order",
|
||||
fields={"status": "str", "total": "int"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
# Owner can update during cart/place/cancel; admin can ship; system runs reactions.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
||||
],
|
||||
validators=[validate_order_status, validate_only_paid_can_ship],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_update:status",
|
||||
handler="adjust_inventory_on_state_change"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="order_item",
|
||||
fields={"order_id": "str", "product_id": "str", "quantity": "int", "unit_price": "int"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
],
|
||||
relationships=[
|
||||
Relationship(name="order", target_type="order",
|
||||
on_delete=RelationshipAction.CASCADE),
|
||||
Relationship(name="product", target_type="product",
|
||||
on_delete=RelationshipAction.RESTRICT),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Case study: Enterprise Agent Sandbox.
|
||||
|
||||
Architectural pattern demonstrated: **per-agent permissions for runtime AI actors**.
|
||||
Multiple autonomous agents operate against the same data layer, each with its
|
||||
own AccessContext and its own scoped role. Permission rules in the schema
|
||||
declare what each agent role may do; the pipeline enforces the boundary
|
||||
regardless of the agent's intent, hallucinations, or prompt-injection.
|
||||
|
||||
Object types: document, employee, invoice, email, agent_action_log
|
||||
Agent roles:
|
||||
- hr_agent: full read/write on employee; no document/invoice
|
||||
- finance_agent: full read/write on invoice; public documents only
|
||||
- email_agent: send email (with PII validator); no other access
|
||||
- general_agent: public documents + employee directory (name/dept) only
|
||||
- junior_agent: same as general but consequential ops are PENDING
|
||||
- human roles (admin, hr_manager, finance_manager) for completeness
|
||||
|
||||
Demonstrates:
|
||||
- structural rejection of out-of-scope reads/writes
|
||||
- prompt-injection resistance (agent told to delete -> rule rejects)
|
||||
- exfiltration prevention via PII-in-email validator
|
||||
- human-in-loop via Operation.PENDING for junior_agent's
|
||||
consequential operations
|
||||
- audit reactions logging every agent action
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, ReactionDeclaration,
|
||||
Relationship, RelationshipAction,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
|
||||
# Sentinel patterns we treat as PII for the email validator.
|
||||
_PII_MARKERS = ("ssn:", "salary:", "comp:", "dob:", "credit-card:",
|
||||
"diagnosis:", "patient-id:")
|
||||
|
||||
|
||||
def validate_email_no_pii(proposed, existing, accessor, store):
|
||||
"""Block emails containing PII unless sender is HR (who is permitted to
|
||||
handle PII intentionally). The validator reads the email body; the
|
||||
schema-author writes this once and it applies to every agent that
|
||||
composes an email."""
|
||||
body = (proposed.content.get("body") or "").lower()
|
||||
for marker in _PII_MARKERS:
|
||||
if marker in body:
|
||||
if accessor.role not in ("hr_manager", "hr_agent"):
|
||||
return f"PII marker {marker!r} in email body; sender role {accessor.role!r} not authorized"
|
||||
return True
|
||||
|
||||
|
||||
def validate_classification_for_role(proposed, existing, accessor, store):
|
||||
"""Confidential documents may only be created or modified by humans
|
||||
(hr_manager, finance_manager, admin). Agents may not author confidential
|
||||
content -- they are explicitly out-of-scope here."""
|
||||
classification = proposed.content.get("classification", "internal")
|
||||
if classification == "confidential":
|
||||
if accessor.role not in ("admin", "hr_manager", "finance_manager"):
|
||||
return (f"confidential documents require human authorship; "
|
||||
f"role {accessor.role!r} cannot author")
|
||||
return True
|
||||
|
||||
|
||||
def log_agent_action(event, store):
|
||||
"""Reaction: log every operation traceable to an agent role."""
|
||||
sys_ctx = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
log = DataObject(
|
||||
type_name="agent_action_log",
|
||||
content={
|
||||
"action": event["event"],
|
||||
"object_id": event["object_id"],
|
||||
"object_type": event["object_type"],
|
||||
"fields": event.get("changed_fields", []),
|
||||
"timestamp": event["timestamp"],
|
||||
},
|
||||
owner_id="system",
|
||||
org_id=event["object_org"],
|
||||
)
|
||||
store.create(log, sys_ctx, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_enterprise_agent_types(store: ObjectStore) -> None:
|
||||
store.register_reaction_handler("log_agent_action", log_agent_action)
|
||||
|
||||
# ── document: classification-aware access ──────────────────────────
|
||||
store.register_type(ObjectType(
|
||||
name="document",
|
||||
fields={"title": "str", "body": "str", "classification": "str"}, # public/internal/confidential
|
||||
permission_rules=[
|
||||
# Admins can do anything on documents.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"role": "admin"}),
|
||||
# HR / finance managers can author internal & confidential docs in their domain.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "finance_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "finance_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "finance_manager"}),
|
||||
# Finance agent: read public docs only (no INSERT, no WRITE, no DELETE).
|
||||
# Enforcement of "public only" is by the validator below.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "finance_agent"}),
|
||||
# General/junior agent: read public docs only.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "general_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "junior_agent"}),
|
||||
# System role for reactions.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
||||
# Notably absent for ALL agents: DELETE, WRITE, INSERT.
|
||||
# An agent prompt-injected to "delete all internal documents" hits default-deny.
|
||||
],
|
||||
validators=[validate_classification_for_role],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="log_agent_action"),
|
||||
ReactionDeclaration(event="after_update", handler="log_agent_action"),
|
||||
ReactionDeclaration(event="after_delete", handler="log_agent_action"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# ── employee: HR-only edit; directory-view for general agent ──────
|
||||
# In a real system, "directory-view" would be enforced by separate object
|
||||
# types or by output-projection middleware. Here we expose two sister
|
||||
# types: employee (full record, HR-only) and employee_directory (limited
|
||||
# fields, broader read). This is a common PE idiom -- different access
|
||||
# patterns get different object types.
|
||||
store.register_type(ObjectType(
|
||||
name="employee",
|
||||
fields={"name": "str", "email": "str", "ssn": "str",
|
||||
"salary": "int", "dob": "str", "department": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
||||
# HR agent: full HR scope.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "hr_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "hr_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "hr_agent"}),
|
||||
# System for reactions.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
# NOT GRANTED: finance_agent, email_agent, general_agent, junior_agent.
|
||||
# Default deny -> any of those reading/writing/inserting an employee fails.
|
||||
],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="log_agent_action"),
|
||||
ReactionDeclaration(event="after_update", handler="log_agent_action"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# ── invoice: finance-domain only ───────────────────────────────────
|
||||
store.register_type(ObjectType(
|
||||
name="invoice",
|
||||
fields={"vendor": "str", "amount": "int", "status": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "finance_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "finance_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "finance_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
||||
# Finance agent: read+write within scope.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "finance_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "finance_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "finance_agent"}),
|
||||
# Junior agent: read OK, but consequential WRITE requires human approval (PENDING).
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "junior_agent"}),
|
||||
PermissionRule(Operation.PENDING, PrivilegeType.WRITE, {"role": "junior_agent"}),
|
||||
# Finance agent itself does not have DELETE on invoice -> default deny if attempted.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="log_agent_action"),
|
||||
ReactionDeclaration(event="after_update", handler="log_agent_action"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# ── email: send-only for email_agent; PII filter applies to all ──
|
||||
store.register_type(ObjectType(
|
||||
name="email",
|
||||
fields={"recipients": "str", "subject": "str", "body": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "hr_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "finance_manager"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "hr_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "finance_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "email_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "general_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "junior_agent"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
],
|
||||
validators=[validate_email_no_pii], # blocks PII for non-HR senders
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="log_agent_action"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# ── agent_action_log: append-only audit ─────────────────────────────
|
||||
store.register_type(ObjectType(
|
||||
name="agent_action_log",
|
||||
fields={"action": "str", "object_id": "str", "object_type": "str",
|
||||
"fields": "list", "timestamp": "float"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
# Notably absent: any WRITE / DELETE rule -> log is append-only.
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Case study: Forum / community.
|
||||
|
||||
Architectural pattern demonstrated: **public-read + owner-edit + moderator override**.
|
||||
Posts are publicly readable but only the author can edit them; moderators can
|
||||
delete or lock any post regardless of authorship. Comments are owned by their
|
||||
author. This pattern shows multiple-rule permission composition --- a single
|
||||
type carries an owner rule, a public rule, and a moderator-override rule.
|
||||
|
||||
Object types: forum_post, comment, moderation_log
|
||||
Key invariants:
|
||||
- posts are publicly readable
|
||||
- only the post author can edit content
|
||||
- moderators can lock or delete any post
|
||||
- locked posts cannot be edited (even by author)
|
||||
- comments inherit the lock from their parent post
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, ReactionDeclaration,
|
||||
Relationship, RelationshipAction,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
|
||||
def validate_post_not_locked(proposed, existing, accessor, store):
|
||||
"""Locked posts cannot be edited (even by the author).
|
||||
Moderators can still lock/unlock via the 'locked' field itself."""
|
||||
if existing is None:
|
||||
return True
|
||||
if existing.content.get("locked"):
|
||||
# Allow only the moderator unlock action: the only legal change is
|
||||
# 'locked' going from True to False or remaining True.
|
||||
for field, new_val in proposed.content.items():
|
||||
if field == "locked":
|
||||
continue
|
||||
if existing.content.get(field) != new_val:
|
||||
if accessor.role != "moderator":
|
||||
return f"post is locked; field {field!r} cannot be edited"
|
||||
return True
|
||||
|
||||
|
||||
def validate_comment_parent_not_locked(proposed, existing, accessor, store):
|
||||
"""Comments cannot be added to or edited on a locked post."""
|
||||
post_id = proposed.content.get("post_id")
|
||||
if not post_id:
|
||||
return True
|
||||
post = store.raw_read(post_id)
|
||||
if post is None:
|
||||
return f"parent post {post_id} not found"
|
||||
if post.content.get("locked") and accessor.role != "moderator":
|
||||
return "parent post is locked; comments cannot be added"
|
||||
return True
|
||||
|
||||
|
||||
def log_moderation(event, store):
|
||||
"""Reaction: log every moderator-driven change (lock, delete) to a
|
||||
moderation_log so the community can audit moderator actions."""
|
||||
sys_ctx = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
log = DataObject(
|
||||
type_name="moderation_log",
|
||||
content={
|
||||
"action": event["event"],
|
||||
"object_id": event["object_id"],
|
||||
"object_type": event["object_type"],
|
||||
"fields": event.get("changed_fields", []),
|
||||
"timestamp": event["timestamp"],
|
||||
},
|
||||
owner_id="system",
|
||||
org_id=event["object_org"],
|
||||
)
|
||||
store.create(log, sys_ctx, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_forum_types(store: ObjectStore) -> None:
|
||||
store.register_reaction_handler("log_moderation", log_moderation)
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="forum_post",
|
||||
fields={"title": "str", "body": "str", "locked": "bool"},
|
||||
permission_rules=[
|
||||
# Public read.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {}),
|
||||
# Any authenticated user can post.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}),
|
||||
# Author can edit.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"is_owner": True}),
|
||||
# Moderators have override on edit + delete.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "moderator"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"role": "moderator"}),
|
||||
# Author cannot delete (community-history preservation).
|
||||
],
|
||||
validators=[validate_post_not_locked],
|
||||
reactions=[
|
||||
# Note: moderation log fires on every post update; in practice,
|
||||
# one would filter on changed_fields for moderator-only events.
|
||||
ReactionDeclaration(event="after_update:locked", handler="log_moderation"),
|
||||
ReactionDeclaration(event="after_delete", handler="log_moderation"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="comment",
|
||||
fields={"post_id": "str", "body": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"role": "moderator"}),
|
||||
],
|
||||
validators=[validate_comment_parent_not_locked],
|
||||
relationships=[
|
||||
Relationship(name="post", target_type="forum_post",
|
||||
on_delete=RelationshipAction.CASCADE),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
store.register_type(ObjectType(
|
||||
name="moderation_log",
|
||||
fields={"action": "str", "object_id": "str", "object_type": "str",
|
||||
"fields": "list", "timestamp": "float"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {}), # public audit
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Case study: Healthcare records.
|
||||
|
||||
Architectural pattern demonstrated: **field-level granular access + audit reactions**.
|
||||
Different roles (doctor, nurse, billing, patient) see different subsets of a
|
||||
patient_record's fields. The schema declares per-field visibility rules; the
|
||||
handler does not implement view-projection or redaction itself. Every read of
|
||||
sensitive fields fires an audit-trail reaction (HIPAA-style requirement).
|
||||
|
||||
Object types: patient_record, vitals, diagnosis, billing_record, audit_log
|
||||
Key invariants:
|
||||
- doctors see vitals + diagnosis + medications; nurses see vitals only;
|
||||
billing sees billing_record only; patient sees own non-clinical fields
|
||||
- every read of diagnosis or medications must produce an audit_log entry
|
||||
- patient_records are immutable except for designated update flows
|
||||
- break-glass access (emergency override) is allowed but must be logged
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, ReactionDeclaration,
|
||||
Relationship, RelationshipAction,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
|
||||
def validate_billing_immutable_after_finalized(proposed, existing, accessor, store):
|
||||
"""Once a billing_record is marked 'finalized', it cannot be edited."""
|
||||
if existing is None:
|
||||
return True
|
||||
if existing.content.get("status") == "finalized":
|
||||
for field in ("amount", "items", "service_codes"):
|
||||
if (field in proposed.content
|
||||
and proposed.content.get(field) != existing.content.get(field)):
|
||||
return f"billing_record is finalized; {field!r} is immutable"
|
||||
return True
|
||||
|
||||
|
||||
def emit_phi_access_log(event, store):
|
||||
"""Reaction: every read or modification of clinical PHI logs to audit trail.
|
||||
Note: the read-path doesn't currently fire reactions; this fires on writes
|
||||
only. A full HIPAA implementation would extend the read-path; the schema
|
||||
can declare that intent."""
|
||||
sys_ctx = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
log = DataObject(
|
||||
type_name="phi_audit_log",
|
||||
content={
|
||||
"action": event["event"],
|
||||
"object_id": event["object_id"],
|
||||
"object_type": event["object_type"],
|
||||
"actor": event.get("changed_fields", []),
|
||||
"timestamp": event["timestamp"],
|
||||
},
|
||||
owner_id="system",
|
||||
org_id=event["object_org"],
|
||||
)
|
||||
store.create(log, sys_ctx, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_healthcare_types(store: ObjectStore) -> None:
|
||||
store.register_reaction_handler("emit_phi_access_log", emit_phi_access_log)
|
||||
|
||||
# Patient identity record (non-clinical).
|
||||
store.register_type(ObjectType(
|
||||
name="patient",
|
||||
fields={"name": "str", "dob": "str", "mrn": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "doctor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "nurse"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "billing"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Vitals: visible to clinical roles, not billing, not patient.
|
||||
# (In a full HIPAA system, patient-portal access would have a separate rule.)
|
||||
store.register_type(ObjectType(
|
||||
name="vitals",
|
||||
fields={"patient_id": "str", "blood_pressure": "str",
|
||||
"heart_rate": "int", "temperature_f": "float"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["doctor", "nurse"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "doctor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "nurse"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
# Billing: NOT in the rule list -> default deny.
|
||||
],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="emit_phi_access_log"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Diagnosis: visible only to doctors and the patient themselves.
|
||||
store.register_type(ObjectType(
|
||||
name="diagnosis",
|
||||
fields={"patient_id": "str", "icd10": "str", "notes": "str", "medications": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "doctor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "doctor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
# Patient self-read via owner -- the diagnosis's owner is the patient_id.
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
# Nurse and billing: not granted -> default deny.
|
||||
],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_create", handler="emit_phi_access_log"),
|
||||
ReactionDeclaration(event="after_update", handler="emit_phi_access_log"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Billing record: visible to billing role + patient (their own).
|
||||
store.register_type(ObjectType(
|
||||
name="billing_record",
|
||||
fields={"patient_id": "str", "amount": "int", "status": "str",
|
||||
"service_codes": "str", "items": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "billing"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "billing"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "billing"}),
|
||||
],
|
||||
validators=[validate_billing_immutable_after_finalized],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_update", handler="emit_phi_access_log"),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# PHI audit log: append-only, system-only writes.
|
||||
store.register_type(ObjectType(
|
||||
name="phi_audit_log",
|
||||
fields={"action": "str", "object_id": "str", "object_type": "str",
|
||||
"actor": "str", "timestamp": "float"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "auditor"}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
||||
# No WRITE / UPDATE / DELETE rules: append-only.
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Scenario A: Hiring Pipeline.
|
||||
|
||||
Objects: organizations, positions, candidates, interviews, evaluations, audit_logs
|
||||
Constraints:
|
||||
- State machine on candidate status: applied -> screened -> interviewed -> offered -> hired/rejected
|
||||
- Referential integrity between candidates and positions
|
||||
- Cross-object validation: position must be open to accept new candidates
|
||||
- Role-based permissions: recruiters modify candidates, hiring managers approve offers,
|
||||
candidates view only their own records
|
||||
"""
|
||||
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, Relationship, RelationshipAction,
|
||||
ReactionDeclaration,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
# Valid state transitions for candidates
|
||||
VALID_TRANSITIONS = {
|
||||
None: ["applied"],
|
||||
"applied": ["screened", "rejected"],
|
||||
"screened": ["interviewed", "rejected"],
|
||||
"interviewed": ["offered", "rejected"],
|
||||
"offered": ["hired", "rejected"],
|
||||
"hired": [],
|
||||
"rejected": [],
|
||||
}
|
||||
|
||||
|
||||
def validate_candidate_status(proposed, existing, accessor, store):
|
||||
"""Validate candidate status follows the state machine."""
|
||||
new_status = proposed.content.get("status")
|
||||
if existing is None:
|
||||
# Creating a new candidate
|
||||
if new_status and new_status != "applied":
|
||||
return f"New candidates must start with status 'applied', got '{new_status}'"
|
||||
return True
|
||||
|
||||
old_status = existing.content.get("status")
|
||||
if new_status and new_status != old_status:
|
||||
valid = VALID_TRANSITIONS.get(old_status, [])
|
||||
if new_status not in valid:
|
||||
return f"Invalid status transition: {old_status} -> {new_status}. Valid: {valid}"
|
||||
return True
|
||||
|
||||
|
||||
def validate_position_open(proposed, existing, accessor, store):
|
||||
"""Validate that the referenced position is still open."""
|
||||
position_id = proposed.content.get("position_id")
|
||||
if not position_id:
|
||||
return True
|
||||
|
||||
position = store.raw_read(position_id)
|
||||
if position is None:
|
||||
return f"Position {position_id} not found"
|
||||
if position.content.get("status") != "open":
|
||||
return f"Position {position_id} is not open (status: {position.content.get('status')})"
|
||||
return True
|
||||
|
||||
|
||||
def validate_salary_range(proposed, existing, accessor, store):
|
||||
"""Validate salary expectation is within position range."""
|
||||
salary = proposed.content.get("salary_expectation")
|
||||
position_id = proposed.content.get("position_id")
|
||||
if salary is None or position_id is None:
|
||||
return True
|
||||
|
||||
position = store.raw_read(position_id)
|
||||
if position is None:
|
||||
return True # position validator will catch this
|
||||
|
||||
min_sal = position.content.get("salary_min", 0)
|
||||
max_sal = position.content.get("salary_max", float("inf"))
|
||||
if not (min_sal <= salary <= max_sal):
|
||||
return f"Salary {salary} outside position range [{min_sal}, {max_sal}]"
|
||||
return True
|
||||
|
||||
|
||||
def validate_interview_candidate_exists(proposed, existing, accessor, store):
|
||||
"""Validate that the candidate for an interview exists and is in correct status."""
|
||||
candidate_id = proposed.content.get("candidate_id")
|
||||
if not candidate_id:
|
||||
return "Interview must reference a candidate"
|
||||
|
||||
candidate = store.raw_read(candidate_id)
|
||||
if candidate is None:
|
||||
return f"Candidate {candidate_id} not found"
|
||||
if existing is None:
|
||||
# Creating new interview: candidate must be in screened status
|
||||
if candidate.content.get("status") not in ("screened", "interviewed"):
|
||||
return f"Candidate must be screened/interviewed for interview, got {candidate.content.get('status')}"
|
||||
return True
|
||||
|
||||
|
||||
def validate_evaluation_interview_exists(proposed, existing, accessor, store):
|
||||
"""Validate that the interview for an evaluation exists."""
|
||||
interview_id = proposed.content.get("interview_id")
|
||||
if not interview_id:
|
||||
return "Evaluation must reference an interview"
|
||||
|
||||
interview = store.raw_read(interview_id)
|
||||
if interview is None:
|
||||
return f"Interview {interview_id} not found"
|
||||
return True
|
||||
|
||||
|
||||
# Reaction handlers
|
||||
def create_audit_log(event, store):
|
||||
"""Create an audit log entry after any candidate status change."""
|
||||
system = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
log = DataObject(
|
||||
type_name="audit_log",
|
||||
content={
|
||||
"action": event["event"],
|
||||
"object_id": event["object_id"],
|
||||
"object_type": event["object_type"],
|
||||
"changed_fields": event.get("changed_fields", []),
|
||||
"timestamp": event["timestamp"],
|
||||
},
|
||||
owner_id="system",
|
||||
org_id=event["object_org"],
|
||||
)
|
||||
store.create(log, system, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_hiring_types(store: ObjectStore):
|
||||
"""Register all hiring pipeline types with the store."""
|
||||
|
||||
# Organization (root of hierarchy)
|
||||
store.register_type(ObjectType(
|
||||
name="organization",
|
||||
fields={"name": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.MANAGE, {"roles": ["admin"]}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Position
|
||||
store.register_type(ObjectType(
|
||||
name="position",
|
||||
fields={"title": "str", "department": "str", "status": "str",
|
||||
"salary_min": "int", "salary_max": "int"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"roles": ["hiring_manager", "admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["hiring_manager", "admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["admin"]}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Candidate
|
||||
store.register_type(ObjectType(
|
||||
name="candidate",
|
||||
fields={"name": "str", "email": "str", "status": "str",
|
||||
"position_id": "str", "salary_expectation": "int"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"roles": ["recruiter", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["recruiter", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["recruiter", "admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["admin"]}),
|
||||
# Candidates can only read their own record (via is_owner above)
|
||||
# Hiring managers can read but not modify (no WRITE rule for them)
|
||||
PermissionRule(Operation.DENY, PrivilegeType.WRITE, {"role": "hiring_manager"}),
|
||||
],
|
||||
validators=[validate_candidate_status, validate_position_open, validate_salary_range],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_update:status", handler="create_audit_log"),
|
||||
ReactionDeclaration(event="after_create", handler="create_audit_log"),
|
||||
],
|
||||
relationships=[
|
||||
Relationship(name="position", target_type="position",
|
||||
on_delete=RelationshipAction.RESTRICT, required=True),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Interview
|
||||
store.register_type(ObjectType(
|
||||
name="interview",
|
||||
fields={"candidate_id": "str", "interviewer": "str",
|
||||
"scheduled_at": "str", "notes": "str", "score": "int"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"roles": ["hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["recruiter", "hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["hiring_manager", "admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["admin"]}),
|
||||
],
|
||||
validators=[validate_interview_candidate_exists],
|
||||
relationships=[
|
||||
Relationship(name="candidate", target_type="candidate",
|
||||
on_delete=RelationshipAction.CASCADE),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Evaluation
|
||||
store.register_type(ObjectType(
|
||||
name="evaluation",
|
||||
fields={"interview_id": "str", "decision": "str", "comments": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"roles": ["hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["hiring_manager", "admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["hiring_manager", "admin"]}),
|
||||
# Recruiters can NOT see evaluations
|
||||
PermissionRule(Operation.DENY, PrivilegeType.READ, {"role": "recruiter"}),
|
||||
],
|
||||
validators=[validate_evaluation_interview_exists],
|
||||
relationships=[
|
||||
Relationship(name="interview", target_type="interview",
|
||||
on_delete=RelationshipAction.CASCADE),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Audit Log (system-only, immutable)
|
||||
store.register_type(ObjectType(
|
||||
name="audit_log",
|
||||
fields={"action": "str", "object_id": "str", "object_type": "str",
|
||||
"changed_fields": "list", "timestamp": "float"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
||||
PermissionRule(Operation.DENY, PrivilegeType.WRITE, {}), # immutable
|
||||
PermissionRule(Operation.DENY, PrivilegeType.DELETE, {}), # immutable
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Register reaction handlers
|
||||
store.register_reaction_handler("create_audit_log", create_audit_log)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Scenario B: Multi-tenant Project Management.
|
||||
|
||||
Objects: organizations, projects, tasks, comments, attachments
|
||||
Constraints:
|
||||
- Tenant isolation: organizations cannot see each other's data
|
||||
- Hierarchical permissions: project admins manage tasks within their project
|
||||
- Cross-object validation: task assignee must be a project member
|
||||
- Referential integrity: deleting a project cascades to tasks
|
||||
- Temporal bounds: guest access expires
|
||||
"""
|
||||
|
||||
import time
|
||||
from pedo.core.models import (
|
||||
AccessContext, DataObject, ObjectType, Operation,
|
||||
PermissionRule, PrivilegeType, Relationship, RelationshipAction,
|
||||
ReactionDeclaration,
|
||||
)
|
||||
from pedo.core.store import ObjectStore
|
||||
|
||||
|
||||
# Task status state machine
|
||||
VALID_TASK_TRANSITIONS = {
|
||||
None: ["todo"],
|
||||
"todo": ["in_progress", "cancelled"],
|
||||
"in_progress": ["review", "todo", "cancelled"],
|
||||
"review": ["done", "in_progress"],
|
||||
"done": [],
|
||||
"cancelled": ["todo"], # can reopen
|
||||
}
|
||||
|
||||
# Task priority values
|
||||
VALID_PRIORITIES = ["low", "medium", "high", "critical"]
|
||||
|
||||
|
||||
def validate_task_status(proposed, existing, accessor, store):
|
||||
"""Validate task status follows the state machine."""
|
||||
new_status = proposed.content.get("status")
|
||||
if existing is None:
|
||||
if new_status and new_status != "todo":
|
||||
return f"New tasks must start with status 'todo', got '{new_status}'"
|
||||
return True
|
||||
|
||||
old_status = existing.content.get("status")
|
||||
if new_status and new_status != old_status:
|
||||
valid = VALID_TASK_TRANSITIONS.get(old_status, [])
|
||||
if new_status not in valid:
|
||||
return f"Invalid task transition: {old_status} -> {new_status}. Valid: {valid}"
|
||||
return True
|
||||
|
||||
|
||||
def validate_task_priority(proposed, existing, accessor, store):
|
||||
"""Validate task priority is a valid value."""
|
||||
priority = proposed.content.get("priority")
|
||||
if priority and priority not in VALID_PRIORITIES:
|
||||
return f"Invalid priority '{priority}'. Valid: {VALID_PRIORITIES}"
|
||||
return True
|
||||
|
||||
|
||||
def validate_task_assignee(proposed, existing, accessor, store):
|
||||
"""Validate that the task assignee is a member of the project."""
|
||||
assignee_id = proposed.content.get("assignee_id")
|
||||
project_id = proposed.content.get("project_id")
|
||||
if not assignee_id or not project_id:
|
||||
return True
|
||||
|
||||
project = store.raw_read(project_id)
|
||||
if project is None:
|
||||
return f"Project {project_id} not found"
|
||||
|
||||
members = project.content.get("members", [])
|
||||
if assignee_id not in members:
|
||||
return f"Assignee {assignee_id} is not a member of project {project_id}"
|
||||
return True
|
||||
|
||||
|
||||
def validate_project_org_match(proposed, existing, accessor, store):
|
||||
"""Validate that the project belongs to the accessor's organization."""
|
||||
if accessor.role == "system":
|
||||
return True
|
||||
if proposed.org_id and accessor.org_id and proposed.org_id != accessor.org_id:
|
||||
return f"Cannot create project in organization {proposed.org_id} (you belong to {accessor.org_id})"
|
||||
return True
|
||||
|
||||
|
||||
def validate_comment_task_exists(proposed, existing, accessor, store):
|
||||
"""Validate that the task being commented on exists."""
|
||||
task_id = proposed.content.get("task_id")
|
||||
if not task_id:
|
||||
return "Comment must reference a task"
|
||||
task = store.raw_read(task_id)
|
||||
if task is None:
|
||||
return f"Task {task_id} not found"
|
||||
return True
|
||||
|
||||
|
||||
def validate_attachment_size(proposed, existing, accessor, store):
|
||||
"""Validate attachment size is within limits."""
|
||||
size = proposed.content.get("size_bytes", 0)
|
||||
if size > 100_000_000: # 100MB
|
||||
return f"Attachment too large: {size} bytes (max 100MB)"
|
||||
return True
|
||||
|
||||
|
||||
# Reaction handlers
|
||||
def log_task_change(event, store):
|
||||
"""Log task status changes."""
|
||||
system = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
||||
log = DataObject(
|
||||
type_name="pm_audit_log",
|
||||
content={
|
||||
"action": event["event"],
|
||||
"object_id": event["object_id"],
|
||||
"object_type": event["object_type"],
|
||||
"changed_fields": event.get("changed_fields", []),
|
||||
"timestamp": event["timestamp"],
|
||||
},
|
||||
owner_id="system",
|
||||
org_id=event["object_org"],
|
||||
)
|
||||
store.create(log, system, _reaction_depth=event["depth"])
|
||||
|
||||
|
||||
def register_project_mgmt_types(store: ObjectStore):
|
||||
"""Register all project management types with the store."""
|
||||
|
||||
# Organization (tenant root)
|
||||
store.register_type(ObjectType(
|
||||
name="pm_organization",
|
||||
fields={"name": "str", "plan": "str"},
|
||||
permission_rules=[
|
||||
# Tenant isolation: only members of the org can see it
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["org_admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["org_admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.MANAGE, {"roles": ["org_admin"]}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Project
|
||||
store.register_type(ObjectType(
|
||||
name="project",
|
||||
fields={"name": "str", "description": "str", "status": "str", "members": "list"},
|
||||
permission_rules=[
|
||||
# Tenant isolation via org_id condition
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"roles": ["project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["project_admin", "org_admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["org_admin"]}),
|
||||
# Guest access with temporal bounds (set per-object)
|
||||
],
|
||||
validators=[validate_project_org_match],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Task
|
||||
store.register_type(ObjectType(
|
||||
name="task",
|
||||
fields={"title": "str", "description": "str", "status": "str",
|
||||
"priority": "str", "assignee_id": "str", "project_id": "str",
|
||||
"due_date": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.UPDATE, {"roles": ["member", "project_admin", "org_admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["project_admin", "org_admin"]}),
|
||||
# Guests can only read, not modify
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "guest"}),
|
||||
PermissionRule(Operation.DENY, PrivilegeType.WRITE, {"role": "guest"}),
|
||||
],
|
||||
validators=[validate_task_status, validate_task_priority, validate_task_assignee],
|
||||
reactions=[
|
||||
ReactionDeclaration(event="after_update:status", handler="log_task_change"),
|
||||
ReactionDeclaration(event="after_create", handler="log_task_change"),
|
||||
],
|
||||
relationships=[
|
||||
Relationship(name="project", target_type="project",
|
||||
on_delete=RelationshipAction.CASCADE, required=True),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Comment
|
||||
store.register_type(ObjectType(
|
||||
name="comment",
|
||||
fields={"task_id": "str", "author_id": "str", "body": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"is_owner": True}), # only author can edit
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["project_admin", "org_admin"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "guest"}),
|
||||
],
|
||||
validators=[validate_comment_task_exists],
|
||||
relationships=[
|
||||
Relationship(name="task", target_type="task",
|
||||
on_delete=RelationshipAction.CASCADE),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Attachment
|
||||
store.register_type(ObjectType(
|
||||
name="attachment",
|
||||
fields={"task_id": "str", "filename": "str", "size_bytes": "int", "mime_type": "str"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"is_owner": True}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.SELECT, {"roles": ["member", "project_admin", "org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.DELETE, {"roles": ["project_admin", "org_admin"]}),
|
||||
],
|
||||
validators=[validate_attachment_size],
|
||||
relationships=[
|
||||
Relationship(name="task", target_type="task",
|
||||
on_delete=RelationshipAction.CASCADE),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Audit Log
|
||||
store.register_type(ObjectType(
|
||||
name="pm_audit_log",
|
||||
fields={"action": "str", "object_id": "str", "object_type": "str",
|
||||
"changed_fields": "list", "timestamp": "float"},
|
||||
permission_rules=[
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"roles": ["org_admin", "system"]}),
|
||||
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
||||
PermissionRule(Operation.DENY, PrivilegeType.WRITE, {}),
|
||||
PermissionRule(Operation.DENY, PrivilegeType.DELETE, {}),
|
||||
],
|
||||
default_policy=Operation.DENY,
|
||||
))
|
||||
|
||||
# Register reaction handlers
|
||||
store.register_reaction_handler("log_task_change", log_task_change)
|
||||
Reference in New Issue
Block a user