Authorization Hooks
Grove provides a layered authorization model built around declarative hooks. Rather than scattering permission checks throughout action bodies, you define
authorize,audit, andclassificationhooks that the runtime invokes automatically at well-defined points in the request lifecycle.
Prerequisites: Declaration Grammar, familiarity with action handling. What you'll learn: How to define authorization policies, audit logging, data classification hooks, and break-glass grants in Grove.
Overview
Grove's authorization system consists of three hook types, each executing at a specific phase:
| Hook | Phase | Purpose |
|---|---|---|
authorize | Before action execution | Accept or reject the request |
audit | After action execution | Record who did what |
classification | At field access | Control field-level visibility |
All hooks are optional. If no authorize hook is defined, all requests are permitted (suitable for development; not recommended for production).
Authorize Hook
The authorize hook runs before an action executes. It receives the request context and decides whether to allow the operation.
authorize create_order(ctx) {
if ctx.principal is null {
fail "Authentication required"
}
if not ctx.principal.roles.contains("order:write") {
fail "Insufficient permissions: order:write role required"
}
}
Syntax
AuthorizeDecl = "authorize" ActionName "(" ContextParam ")" "{" { Statement } "}" .
ContextParam = Identifier .
Request Context
The context parameter provides access to the caller's identity and metadata:
| Field | Type | Description |
|---|---|---|
ctx.principal | Principal? | The authenticated caller (null if unauthenticated) |
ctx.principal.id | String | Unique identifier for the principal |
ctx.principal.roles | List<String> | Roles assigned to the principal |
ctx.principal.claims | Map<String, Value> | Arbitrary claims from the auth token |
ctx.action | String | Name of the action being invoked |
ctx.module | String | Name of the module containing the action |
ctx.timestamp | DateTime | Time the request was received |
ctx.ip_address | String? | Client IP address, if available |
Where ctx.principal comes from
Grove actions never validate JWTs directly. The edge validates the caller's token, extracts the claims into HTTP headers, and signs the request with an HMAC so the backend can trust the identity headers. The Grove runtime then maps those headers onto ctx.principal (X-Sub → ctx.principal.id, X-Email → ctx.principal.claims.email, and so on).
ctx.principal is null for anonymous requests — either because the request hit a public route or because the user is not signed in. An authorize hook that requires authentication should guard against this explicitly (see the examples below).
See Identity Flow for the full list of edge-stamped headers and the trust-boundary story.
Deny by Default
An authorize hook must explicitly permit the request by completing without a fail. There is no allow statement; the absence of fail constitutes authorization.
// Deny by default: only admins can delete
authorize delete_order(ctx) {
if ctx.principal is null {
fail "Authentication required"
}
if not ctx.principal.roles.contains("admin") {
fail "Only administrators can delete orders"
}
}
Resource-Scoped Authorization
To authorize based on the specific resource being acted upon, the hook can access the action's parameters:
authorize update_order(ctx, order_id: Id) {
let order = query_order(order_id)
if order.owner_id != ctx.principal.id {
if not ctx.principal.roles.contains("admin") {
fail "You can only update your own orders"
}
}
}
Audit Hook
The audit hook runs after an action completes successfully. It records an immutable audit trail of operations.
audit create_order(ctx, result) {
log {
action: "create_order",
principal: ctx.principal.id,
order_id: result.order_id,
timestamp: ctx.timestamp,
}
}
Syntax
AuditDecl = "audit" ActionName "(" ContextParam "," ResultParam ")" "{" { Statement } "}" .
Audit Log Entries
The log statement within an audit hook appends a structured record to the audit log. Audit logs are append-only and stored separately from the event stream.
audit delete_order(ctx, result) {
log {
action: "delete_order",
principal: ctx.principal.id,
reason: "User requested deletion",
ip_address: ctx.ip_address,
timestamp: ctx.timestamp,
}
}
Classification Hook
Classification hooks control field-level data visibility based on the caller's identity and clearance level. They work in conjunction with @pii and @confidential field annotations.
classification(ctx) {
if ctx.principal.roles.contains("support:full") {
reveal @pii
reveal @confidential
} else if ctx.principal.roles.contains("support:basic") {
reveal @pii
// @confidential fields remain redacted
}
// By default, annotated fields are redacted
}
See Field Classifications for annotation details.
Break-Glass Grants
Break-glass grants provide emergency access that bypasses normal authorization. They are time-limited and fully audited.
Granting Break-Glass Access
authorize transfer_funds(ctx) {
if ctx.principal.has_break_glass {
// Log the elevated access but allow it
audit_break_glass(ctx, "transfer_funds")
return
}
// Normal authorization follows
if not ctx.principal.roles.contains("finance:write") {
fail "Insufficient permissions"
}
}
Break-Glass Properties
| Property | Description |
|---|---|
ctx.principal.has_break_glass | Bool -- whether a break-glass grant is active |
ctx.principal.break_glass_reason | String? -- the stated reason for the grant |
ctx.principal.break_glass_expires_at | DateTime? -- when the grant expires |
ctx.principal.break_glass_granted_by | String? -- who approved the grant |
Behavior
- Break-glass grants are issued out-of-band (through an admin API or operations console).
- Every action executed under a break-glass grant is automatically marked in the audit log, regardless of whether a custom
audithook is defined. - Break-glass grants have a maximum duration (configurable, default 4 hours).
- After expiration, normal authorization rules resume.
Combining Hooks
Multiple authorization strategies can be combined:
// Module-level default: require authentication
authorize *(ctx) {
if ctx.principal is null {
fail "Authentication required"
}
}
// Action-specific override: additionally require admin role
authorize delete_order(ctx) {
if not ctx.principal.roles.contains("admin") {
fail "Admin role required"
}
}
The wildcard * form applies to all actions in the module that do not have a specific authorize hook. Action-specific hooks take precedence over the wildcard.
Per-Project Cedar Policy
Alongside the authorize hook model above, Grove supports evaluating a project-level Cedar policy on every action and query invocation. This is useful when you want a single declarative policy file that covers many actions at once, or when you want to lean on Cedar's tooling and analyzability rather than imperative hooks.
Enabling Cedar
Place a policies.cedar file at the root of your Grove project:
my-app/
grove.toml
policies.cedar # ← detected automatically
modules/
order/
module.grove
On manzano deploy, the CLI includes policies.cedar in the deploy tarball (see CLI — deploy). The runtime auto-discovers the file and enables Cedar evaluation. If the file is absent, Cedar evaluation is disabled and authorization falls back to authorize hooks only — no regression for projects that don't opt in.
Cedar evaluation runs in addition to authorize hooks, not instead of them. Both must permit the request for it to proceed. This lets you use Cedar for coarse cross-cutting policy (e.g. "anonymous users cannot write") and hooks for fine-grained per-action checks.
Role Vocabulary
Cedar principals can be given roles through Grove's declared-role vocabulary. Roles are resolved at runtime from the authenticated identity (see Identity Flow) or from custom logic you plug in at the runtime-store level.
Sentinel Roles
Two special role values are always available in Cedar policies:
| Sentinel | When it fires |
|---|---|
Role::"__authenticated" | Request has a non-null ctx.principal. Any identity-provider sign-in counts. |
Role::"__anonymous" | Request has ctx.principal == null — no identity was provided. |
Use these to write policies that apply uniformly across authenticated or anonymous traffic without enumerating every real role.
// Anonymous visitors can read published posts.
permit (
principal in Role::"__anonymous",
action == Action::"read_post",
resource
)
when { resource.status == "published" };
// Authenticated users can comment on any post.
permit (
principal in Role::"__authenticated",
action == Action::"post_comment",
resource
);
Anonymous route calls are now truly anonymous. Earlier Grove versions used a synthetic "route" principal as a fallback when an unauthenticated request reached a route. That fallback has been removed: anonymous route calls now flow through authorization as anonymous (matching Role::"__anonymous"). If your existing Cedar policies or authorize hooks relied on the old synthetic principal, update them to either require authentication or permit Role::"__anonymous" explicitly.
See Also
- Authentication Overview -- where
ctx.principalcomes from - Identity Flow -- edge-stamped headers and HMAC trust boundary
- Field Classifications --
@piiand@confidentialannotations - Credential Store -- managing secrets used in authorization
- Webhook Security -- authorization for inbound webhooks
- Semantic Constraints -- rules governing hook declarations