Authorization Hooks

Grove provides a layered authorization model built around declarative hooks. Rather than scattering permission checks throughout action bodies, you define authorize, audit, and classification hooks 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:

HookPhasePurpose
authorizeBefore action executionAccept or reject the request
auditAfter action executionRecord who did what
classificationAt field accessControl 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:

FieldTypeDescription
ctx.principalPrincipal?The authenticated caller (null if unauthenticated)
ctx.principal.idStringUnique identifier for the principal
ctx.principal.rolesList<String>Roles assigned to the principal
ctx.principal.claimsMap<String, Value>Arbitrary claims from the auth token
ctx.actionStringName of the action being invoked
ctx.moduleStringName of the module containing the action
ctx.timestampDateTimeTime the request was received
ctx.ip_addressString?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

PropertyDescription
ctx.principal.has_break_glassBool -- whether a break-glass grant is active
ctx.principal.break_glass_reasonString? -- the stated reason for the grant
ctx.principal.break_glass_expires_atDateTime? -- when the grant expires
ctx.principal.break_glass_granted_byString? -- who approved the grant

Behavior

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:

SentinelWhen 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