Access Control Patterns¶
Access control contracts determine who can use which tools in which environments. They are preconditions -- they evaluate before the tool runs, and denial is free because nothing has happened yet.
Role-Based Access¶
The most common pattern. Use principal.role with in or not_in to restrict tools to specific roles.
When to use: You have a fixed set of roles (admin, analyst, viewer) and want to gate dangerous tools behind privileged roles.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: role-based-access
defaults:
mode: enforce
contracts:
- id: admin-only-deploy
type: pre
tool: deploy_service
when:
principal.role:
not_in: [admin, sre]
then:
effect: deny
message: "Only admin and sre roles can deploy. Your role: {principal.role}."
tags: [access-control, production]
- id: viewer-read-only
type: pre
tool: run_command
when:
principal.role:
equals: viewer
then:
effect: deny
message: "Viewer role cannot execute commands. Request analyst or admin access."
tags: [access-control]
from edictum import Verdict, precondition
@precondition("deploy_service")
def admin_only_deploy(envelope):
if envelope.principal and envelope.principal.role not in ("admin", "sre"):
return Verdict.fail(
f"Only admin and sre roles can deploy. Your role: {envelope.principal.role}."
)
return Verdict.pass_()
@precondition("run_command")
def viewer_read_only(envelope):
if envelope.principal and envelope.principal.role == "viewer":
return Verdict.fail(
"Viewer role cannot execute commands. Request analyst or admin access."
)
return Verdict.pass_()
Gotchas:
- If no principal is attached to the call, principal.role is missing. Missing fields cause the leaf to evaluate to false, so the contract does not fire. This means unauthenticated calls slip through. Add a separate principal.role: { exists: false } contract to catch missing principals.
- The not_in operator checks whether the value is absent from the list. not_in: [admin, sre] denies everyone except admin and sre -- including missing roles (which evaluate to false, not firing the contract). Pair with an exists check for defense in depth.
Environment-Aware Contracts¶
Restrict tools based on the deployment environment. The environment selector resolves from the environment parameter set when you construct the Edictum instance.
When to use: Different environments have different risk profiles. Production needs stricter controls than staging or development.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: environment-gates
defaults:
mode: enforce
contracts:
- id: prod-requires-admin
type: pre
tool: run_command
when:
all:
- environment: { equals: production }
- principal.role: { not_in: [admin, sre] }
then:
effect: deny
message: "Production commands require admin or sre role."
tags: [access-control, production]
- id: staging-no-write
type: pre
tool: query_database
when:
all:
- environment: { equals: staging }
- args.query: { matches: '\\b(INSERT|UPDATE|DELETE)\\b' }
then:
effect: deny
message: "Write queries are denied in staging. Use read-only queries."
tags: [access-control, staging]
import re
from edictum import Verdict, precondition
@precondition("run_command")
def prod_requires_admin(envelope):
if envelope.environment != "production":
return Verdict.pass_()
if envelope.principal and envelope.principal.role not in ("admin", "sre"):
return Verdict.fail("Production commands require admin or sre role.")
return Verdict.pass_()
@precondition("query_database")
def staging_no_write(envelope):
if envelope.environment != "staging":
return Verdict.pass_()
query = envelope.args.get("query", "")
if re.search(r'\b(INSERT|UPDATE|DELETE)\b', query):
return Verdict.fail("Write queries are denied in staging. Use read-only queries.")
return Verdict.pass_()
Gotchas:
- The environment value is set at Edictum construction time, not per-call. If your application uses a single Edictum instance across environments, environment-based contracts will always see the same value.
- Regex patterns in matches use single-quoted YAML strings. Double-quoted strings interpret \b as a backspace character instead of a regex word boundary.
Attribute-Based Access¶
Use principal.claims.<key> to access custom attributes beyond the built-in fields. Claims are arbitrary key-value pairs attached to the principal.
When to use: Your authorization model goes beyond simple roles. You need to check department, clearance level, team membership, or other domain-specific attributes.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: attribute-based-access
defaults:
mode: enforce
contracts:
- id: require-clearance-for-sensitive-data
type: pre
tool: query_database
when:
all:
- args.table: { in: [audit_logs, access_records, user_sessions] }
- principal.claims.clearance: { not_in: [high, critical] }
then:
effect: deny
message: "Querying '{args.table}' requires high or critical clearance."
tags: [access-control, sensitive-data]
- id: department-restricted-tool
type: pre
tool: send_email
when:
principal.claims.department:
not_in: [marketing, communications]
then:
effect: deny
message: "Only marketing and communications can use send_email."
tags: [access-control, department]
from edictum import Verdict, precondition
@precondition("query_database")
def require_clearance_for_sensitive_data(envelope):
table = envelope.args.get("table", "")
sensitive_tables = ["audit_logs", "access_records", "user_sessions"]
if table not in sensitive_tables:
return Verdict.pass_()
clearance = (envelope.principal.claims.get("clearance") if envelope.principal else None)
if clearance not in ("high", "critical"):
return Verdict.fail(
f"Querying '{table}' requires high or critical clearance."
)
return Verdict.pass_()
@precondition("send_email")
def department_restricted_tool(envelope):
dept = (envelope.principal.claims.get("department") if envelope.principal else None)
if dept not in ("marketing", "communications"):
return Verdict.fail("Only marketing and communications can use send_email.")
return Verdict.pass_()
Gotchas:
- Claims are set by your application when constructing the Principal object. Edictum does not validate claims against an external identity provider.
- If a claim key is missing, the leaf evaluates to false and the contract does not fire. Use principal.claims.<key>: { exists: false } to explicitly require a claim be present.
Role Escalation Prevention¶
Block actions that would change a user's own role or permissions. This prevents agents from self-promoting or modifying access controls.
When to use: Your agent has access to user management or configuration tools, and you want to prevent it from escalating privileges.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: escalation-prevention
defaults:
mode: enforce
contracts:
- id: block-role-change
type: pre
tool: run_command
when:
any:
- args.command: { contains: "role" }
- args.command: { contains: "permission" }
- args.command: { contains: "grant" }
then:
effect: deny
message: "Commands that modify roles or permissions are denied."
tags: [access-control, escalation]
- id: block-admin-config-writes
type: pre
tool: write_file
when:
args.path:
contains_any: ["rbac", "permissions", "roles.yaml", "access-control"]
then:
effect: deny
message: "Writing to access control configuration files is denied."
tags: [access-control, escalation]
from edictum import Verdict, precondition
@precondition("run_command")
def block_role_change(envelope):
cmd = envelope.args.get("command", "")
for keyword in ("role", "permission", "grant"):
if keyword in cmd:
return Verdict.fail("Commands that modify roles or permissions are denied.")
return Verdict.pass_()
@precondition("write_file")
def block_admin_config_writes(envelope):
path = envelope.args.get("path", "")
for keyword in ("rbac", "permissions", "roles.yaml", "access-control"):
if keyword in path:
return Verdict.fail("Writing to access control configuration files is denied.")
return Verdict.pass_()
Gotchas:
- The contains operator is a substring match, which can produce false positives. A command like echo "user role in report" would be caught. Use matches with word boundaries for more precise matching when needed.
- Escalation prevention is defense in depth. It should complement your application's own authorization layer, not replace it.