The Authz plugin provides a declarative, protocol-buffer-based authorization system for Prefab servers. It uses proto annotations to define access control rules and enforces them via a gRPC interceptor.
import (
"github.com/dpup/prefab"
"github.com/dpup/prefab/plugins/auth"
"github.com/dpup/prefab/plugins/authz"
)
// 1. Define roles
const (
roleUser = authz.Role("user")
roleOwner = authz.Role("owner")
roleAdmin = authz.Role("admin")
)
// 2. Set up authorization plugin
s := prefab.New(
prefab.WithPlugin(auth.Plugin()),
prefab.WithPlugin(authz.Plugin(
// Define policies (effect, role, action)
authz.WithPolicy(authz.Allow, roleUser, authz.Action("documents.view")),
authz.WithPolicy(authz.Allow, roleOwner, authz.Action("documents.edit")),
authz.WithPolicy(authz.Allow, roleAdmin, authz.Action("*")),
// Register object fetcher
authz.WithObjectFetcher("document", authz.AsObjectFetcher(
authz.Fetcher(db.GetDocumentByID),
)),
// Register role describer
authz.WithRoleDescriber("document", authz.Compose(
authz.OwnershipRole(roleOwner, func(doc *Document) string {
return doc.OwnerID
}),
)),
)),
)Roles are application-defined strings representing user capabilities (e.g., "admin", "owner", "viewer"). Roles are context-dependent and determined per-object by Role Describers.
Actions are application-defined strings representing operations (e.g., "documents.view", "documents.edit"). Actions are declared in proto annotations.
Policies map roles to actions with an effect (Allow or Deny):
authz.WithPolicy(authz.Allow, roleEditor, authz.Action("documents.edit"))
authz.WithPolicy(authz.Deny, roleSuspended, authz.Action("*"))When an RPC is invoked, Prefab enforces authorization through this sequence:
-
Extract metadata from proto annotations:
- Action (e.g., "documents.view")
- Resource type (e.g., "document")
- Resource ID from
[(prefab.authz.id) = true]field - Scope from
[(prefab.authz.scope) = true]field (optional)
-
Fetch the resource object:
- Calls the Object Fetcher registered for the resource type
- Object Fetcher receives the resource ID and returns the actual object
- Example:
"doc-123"→Document{ID: "doc-123", OwnerID: "user-456"}
-
Determine user roles:
- Calls the Role Describer with (user identity, object, scope)
- Role Describer examines the object and returns applicable roles
- Example: If user owns the document →
[owner, editor]
-
Evaluate policies:
- Checks policies for each role using AWS IAM-style precedence
- Explicit Deny wins > Explicit Allow > Default effect
-
Grant or deny access based on the final effect
Prefab uses AWS IAM-style precedence for clear, predictable authorization:
- Explicit Deny wins: If ANY of the user's roles has a Deny policy for the action, access is denied
- Explicit Allow wins: If no Deny exists and ANY role has an Allow policy, access is granted
- Default effect: If no policies match any role, use the
default_effectfrom the RPC method
Benefits:
- Create "blocklist" roles that override other permissions (e.g., suspended users)
- Grant permissions safely knowing deny policies provide ultimate control
- Predictable behavior aligned with industry standards (AWS IAM)
Example:
// User has roles: [editor, suspended]
authz.WithPolicy(authz.Allow, roleEditor, authz.Action("documents.write"))
authz.WithPolicy(authz.Deny, roleSuspended, authz.Action("*"))
// Result: Denied (explicit deny wins over allow)Annotate your RPC methods with authorization metadata:
import "plugins/authz/authz.proto";
rpc GetDocument(GetDocumentRequest) returns (GetDocumentResponse) {
option (prefab.authz.action) = "documents.view";
option (prefab.authz.resource) = "document";
option (prefab.authz.default_effect) = "deny"; // Optional, defaults to "deny"
option (google.api.http) = {
get: "/api/workspaces/{workspace_id}/documents/{document_id}"
};
}Available options:
(prefab.authz.action)- The action being performed (e.g., "documents.view")(prefab.authz.resource)- The resource type (maps to registered Object Fetcher)(prefab.authz.default_effect)- Default effect if no policy matches: "allow" or "deny"
Mark request fields with authorization metadata:
message GetDocumentRequest {
string workspace_id = 1 [(prefab.authz.scope) = true]; // Optional scope
string document_id = 2 [(prefab.authz.id) = true]; // Required resource ID
}Available options:
[(prefab.authz.id) = true]- Marks the field containing the resource identifier[(prefab.authz.scope) = true]- Marks the field containing the scope identifier (optional)
syntax = "proto3";
package docservice;
import "google/api/annotations.proto";
import "plugins/authz/authz.proto";
service DocumentService {
rpc ListDocuments(ListDocumentsRequest) returns (ListDocumentsResponse) {
option (prefab.authz.action) = "documents.list";
option (prefab.authz.resource) = "workspace";
option (google.api.http) = {
get: "/api/workspaces/{workspace_id}/documents"
};
}
rpc GetDocument(GetDocumentRequest) returns (GetDocumentResponse) {
option (prefab.authz.action) = "documents.view";
option (prefab.authz.resource) = "document";
option (prefab.authz.default_effect) = "deny";
option (google.api.http) = {
get: "/api/workspaces/{workspace_id}/documents/{document_id}"
};
}
rpc UpdateDocument(UpdateDocumentRequest) returns (UpdateDocumentResponse) {
option (prefab.authz.action) = "documents.update";
option (prefab.authz.resource) = "document";
option (prefab.authz.default_effect) = "deny";
option (google.api.http) = {
put: "/api/workspaces/{workspace_id}/documents/{document_id}"
body: "*"
};
}
}
message ListDocumentsRequest {
string workspace_id = 1 [(prefab.authz.id) = true];
}
message GetDocumentRequest {
string workspace_id = 1 [(prefab.authz.scope) = true];
string document_id = 2 [(prefab.authz.id) = true];
}
message UpdateDocumentRequest {
string workspace_id = 1 [(prefab.authz.scope) = true];
string document_id = 2 [(prefab.authz.id) = true];
string title = 3;
string content = 4;
}Object Fetchers convert resource IDs into actual objects that can be examined by Role Describers.
// Register a fetcher for "document" resources
authz.WithObjectFetcher("document", authz.AsObjectFetcher(
authz.Fetcher(func(ctx context.Context, id string) (*Document, error) {
return db.GetDocumentByID(ctx, id)
}),
))
// Or pass the function directly if signatures match
authz.WithObjectFetcher("document", authz.AsObjectFetcher(
authz.Fetcher(db.GetDocumentByID),
))The fetcher receives the value from the [(prefab.authz.id) = true] field and returns the actual object.
Prefab provides composable, type-safe patterns for building object fetchers:
The foundational pattern for wrapping any fetch function:
authz.Fetcher(func(ctx context.Context, id string) (*Document, error) {
return db.GetDocumentByID(ctx, id)
})Useful for tests, examples, or small static datasets:
staticDocuments := map[string]*Document{
"1": {ID: "1", Title: "Doc 1"},
"2": {ID: "2", Title: "Doc 2"},
}
authz.MapFetcher(staticDocuments)Wrap a fetcher with validation logic (e.g., soft-delete checks):
authz.ValidatedFetcher(
authz.Fetcher(db.GetDocumentByID),
func(doc *Document) error {
if doc.Deleted {
return errors.NewC("document deleted", codes.NotFound)
}
if doc.Archived {
return errors.NewC("document archived", codes.PermissionDenied)
}
return nil
},
)Try multiple fetchers in order (cache → database → API):
authz.ComposeFetchers(
authz.MapFetcher(cache), // Try cache first
authz.Fetcher(db.GetDocumentByID), // Then database
authz.Fetcher(api.FetchDocument), // Finally remote API
)Transform the key before fetching:
// Convert string IDs to int IDs
authz.TransformKey(
func(id string) int { return parseID(id) },
authz.Fetcher(db.GetDocumentByNumericID),
)Return a default value instead of an error:
authz.DefaultFetcher(
authz.Fetcher(db.GetUserByID),
&User{ID: "guest", Name: "Guest User"},
)authz.WithObjectFetcher("org", authz.AsObjectFetcher(
authz.ComposeFetchers(
// Try cache first
authz.MapFetcher(cache),
// Then validated database fetch
authz.ValidatedFetcher(
authz.Fetcher(db.GetOrgByID),
func(org *Org) error {
if org.Deleted {
return errors.NewC("org deleted", codes.NotFound)
}
return nil
},
),
// Finally try remote API
authz.Fetcher(api.FetchOrg),
),
))Role Describers determine what roles a user has for a specific object and scope.
authz.WithRoleDescriber("document", authz.Compose(
// Grant owner role if user owns the document
authz.OwnershipRole(authz.RoleOwner, func(doc *Document) string {
return doc.OwnerID
}),
// Grant editor role based on workspace membership
authz.MembershipRoles(
func(doc *Document) string { return doc.WorkspaceID },
func(ctx context.Context, workspaceID string, identity auth.Identity) ([]authz.Role, error) {
return getWorkspaceRoles(ctx, workspaceID, identity.Subject)
},
),
))Role describers receive (identity, object, scope) and return a list of roles.
Prefab provides composable, type-safe patterns for building role describers:
Combines multiple role describers and provides automatic scope validation for ScopedObject:
authz.Compose(
authz.OwnershipRole(...),
authz.StaticRole(...),
authz.MembershipRoles(...),
)If the object implements ScopedObject, Compose automatically validates that object.ScopeID() == scope before calling describers. If the scope doesn't match, it returns empty roles.
Grants a role if the user owns the resource:
authz.OwnershipRole(authz.RoleOwner, func(doc *Document) string {
return doc.OwnerID
})Grants a role based on an async predicate (i.e. it might error, useful for database queries):
authz.ConditionalRole(authz.RoleEditor, func(ctx context.Context, identity auth.Identity, doc *Document, scope authz.Scope) (bool, error) {
// Check if user has edit permission in database
return db.HasEditPermission(ctx, identity.Subject, doc.ID)
})Grants a role based on a sync predicate (i.e. an error isn't possible):
authz.StaticRole(authz.RoleViewer, func(_ context.Context, _ auth.Identity, doc *Document, _ authz.Scope) bool {
return doc.Published
})Returns multiple roles based on conditions:
authz.StaticRoles(func(ctx context.Context, identity auth.Identity, doc *Document, scope authz.Scope) []authz.Role {
var roles []authz.Role
if doc.Published {
roles = append(roles, authz.RoleViewer)
}
if doc.Featured {
roles = append(roles, "featured-viewer")
}
return roles
})Grants a role based on context only (no object examination):
authz.GlobalRole(authz.RoleAdmin, func(ctx context.Context, identity auth.Identity, scope authz.Scope) (bool, error) {
// Check if user is a superuser
return db.IsSuperuser(ctx, identity.Subject)
})Grants roles based on parent resource membership:
authz.MembershipRoles(
// Extract parent ID from object
func(doc *Document) string { return doc.WorkspaceID },
// Fetch roles from parent
func(ctx context.Context, workspaceID string, identity auth.Identity) ([]authz.Role, error) {
workspace, err := fetchWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
return workspace.GetUserRoles(ctx, identity.Subject)
},
)Security Note: MembershipRoles automatically validates that the object's scope ID matches the authorization scope parameter. This prevents scope confusion attacks where a request to /api/orgs/123/documents/456 could access a document that actually belongs to org 999.
authz.WithRoleDescriber("document", authz.Compose(
// Grant owner role if user owns the document
authz.OwnershipRole(authz.RoleOwner, func(doc *Document) string {
return doc.OwnerID
}),
// Grant viewer role if document is published
authz.StaticRole(authz.RoleViewer, func(_ context.Context, _ auth.Identity, doc *Document) bool {
return doc.Published
}),
// Grant workspace roles based on membership
authz.MembershipRoles(
func(doc *Document) string { return doc.WorkspaceID },
func(ctx context.Context, workspaceID string, identity auth.Identity) ([]authz.Role, error) {
workspace, err := fetchWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
return workspace.GetUserRoles(ctx, identity.Subject)
},
),
// Grant admin role to superusers
authz.GlobalRole(authz.RoleAdmin, func(ctx context.Context, identity auth.Identity, _ authz.Scope) (bool, error) {
return db.IsSuperuser(ctx, identity.Subject)
}),
))The scope parameter represents the "container" of the object being accessed:
- Document = Object, Workspace = Scope
- Note = Object, Folder = Scope
- File = Object, Organization = Scope
When using Compose with objects that implement ScopedObject, scope validation is automatic:
type Document struct {
ID string
WorkspaceID string
OwnerID string
}
func (d *Document) AuthzType() string { return "document" }
func (d *Document) ScopeID() string { return d.WorkspaceID }If the object's ScopeID() doesn't match the request scope, Compose returns empty roles.
For custom role describers, check scope manually:
func describeRoles(ctx context.Context, identity auth.Identity, object any, scope authz.Scope) ([]authz.Role, error) {
doc := object.(*Document)
// Check scope matches
if string(scope) != doc.WorkspaceID {
return []authz.Role{}, nil
}
// Return roles...
return roles, nil
}Roles are just strings, so you can define your own:
const (
// Framework-provided roles
roleAdmin = authz.RoleAdmin // "admin"
roleEditor = authz.RoleEditor // "editor"
roleViewer = authz.RoleViewer // "viewer"
roleOwner = authz.RoleOwner // "owner"
// Custom roles
reviewer = authz.Role("reviewer")
contributor = authz.Role("contributor")
moderator = authz.Role("moderator")
)
authz.WithRoleDescriber("pull_request", authz.Compose(
// Use framework roles
authz.OwnershipRole(roleOwner, func(pr *PullRequest) string {
return pr.AuthorID
}),
// Use custom roles
authz.ConditionalRole(reviewer, func(_ context.Context, identity auth.Identity, pr *PullRequest, _ authz.Scope) (bool, error) {
for _, r := range pr.Reviewers {
if r == identity.Subject {
return true, nil
}
}
return false, nil
}),
))Establish a role hierarchy where parent roles inherit child roles:
authz.WithRoleHierarchy(authz.RoleAdmin, authz.RoleEditor, authz.RoleViewer, authz.RoleUser)In this example:
- Admins inherit all editor permissions
- Editors inherit viewer permissions
- Viewers inherit user permissions
Example:
authz.Plugin(
authz.WithPolicy(authz.Allow, authz.RoleViewer, authz.Action("documents.view")),
authz.WithPolicy(authz.Allow, authz.RoleEditor, authz.Action("documents.edit")),
authz.WithRoleHierarchy(authz.RoleAdmin, authz.RoleEditor, authz.RoleViewer),
)
// User with "editor" role can:
// - documents.view (inherited from viewer)
// - documents.edit (direct permission)
// User with "admin" role can:
// - documents.view (inherited from editor → viewer)
// - documents.edit (inherited from editor)Use wildcards in policies for broad permissions:
// Admin can perform any action
authz.WithPolicy(authz.Allow, authz.RoleAdmin, authz.Action("*"))
// Suspended users are denied everything
authz.WithPolicy(authz.Deny, roleSuspended, authz.Action("*"))Use wildcards in object fetchers and role describers for default handling:
// Default role describer for all resource types
authz.WithRoleDescriber("*", func(ctx context.Context, identity auth.Identity, object any, scope authz.Scope) ([]authz.Role, error) {
// Grant basic user role to all authenticated users
return []authz.Role{authz.RoleUser}, nil
})For complex setups, use the builder pattern:
builder := authz.NewBuilder().
WithPolicy(authz.Allow, roleUser, authz.Action("documents.view")).
WithPolicy(authz.Allow, roleOwner, authz.Action("documents.edit")).
WithPolicy(authz.Allow, roleAdmin, authz.Action("*")).
WithRoleHierarchy(roleAdmin, roleEditor, roleViewer, roleUser).
WithObjectFetcher("document", authz.AsObjectFetcher(
authz.Fetcher(db.GetDocumentByID),
)).
WithRoleDescriber("document", authz.Compose(
authz.OwnershipRole(roleOwner, func(doc *Document) string {
return doc.OwnerID
}),
))
s := prefab.New(
prefab.WithPlugin(auth.Plugin()),
prefab.WithPlugin(builder.Build()),
)For standard CRUD operations, define policies for common actions:
builder := authz.NewBuilder().
// CRUD policies
WithPolicy(authz.Allow, authz.RoleViewer, authz.ActionRead).
WithPolicy(authz.Allow, authz.RoleEditor, authz.ActionCreate).
WithPolicy(authz.Allow, authz.RoleEditor, authz.ActionRead).
WithPolicy(authz.Allow, authz.RoleEditor, authz.ActionUpdate).
WithPolicy(authz.Allow, authz.RoleAdmin, authz.ActionDelete).
WithPolicy(authz.Allow, authz.RoleAdmin, authz.Action("*")).
// Object fetcher and role describer
WithObjectFetcher("document", authz.AsObjectFetcher(
authz.Fetcher(db.GetDocumentByID),
)).
WithRoleDescriber("document", authz.Compose(
authz.OwnershipRole(authz.RoleOwner, func(doc *Document) string {
return doc.OwnerID
}),
))
s := prefab.New(
prefab.WithPlugin(auth.Plugin()),
prefab.WithPlugin(builder.Build()),
)Common CRUD actions are predefined:
authz.ActionCreate- Create operationsauthz.ActionRead- Read operationsauthz.ActionUpdate- Update operationsauthz.ActionDelete- Delete operations
The authz plugin provides a debug endpoint at /debug/authz that shows:
- Registered policies
- Role hierarchy
- Registered object fetchers and role describers
Authorization decisions are logged with structured fields for debugging:
authz.action- The action being evaluatedauthz.resource- The resource typeauthz.objectID- The ID of the object being accessedauthz.scope- The scope (if specified)authz.roles- The roles assigned to the userauthz.evaluated_policies- List of policies that were evaluated (role + effect)authz.effect- The final effect (ALLOW/DENY)authz.reason- Why access was granted or denied
Example log output:
{
"authz.action": "documents.write",
"authz.resource": "document",
"authz.objectID": "doc-123",
"authz.roles": ["editor", "suspended"],
"authz.evaluated_policies": [
{"role": "editor", "effect": "ALLOW"},
{"role": "suspended", "effect": "DENY"}
],
"authz.effect": "DENY",
"authz.reason": "denied by policy"
}When access is denied, users receive clear, actionable error messages:
Before:
Error: you are not authorized to perform this action
After:
Error: Access denied: explicitly denied by role 'suspended'
The error message explains why access was denied based on the evaluated policies:
- "no roles assigned" - User has no roles for this resource
- "no policies match action 'X' for your roles" - No policies cover this action
- "explicitly denied by role 'X'" - A deny policy blocked access
- "action 'X' not explicitly allowed (default: deny)" - No allow policy matched
Configure an audit logger to receive all authorization decisions for compliance and security monitoring:
authz.WithAuditLogger(func(ctx context.Context, decision authz.AuthzDecision) {
log.Printf("authz: user=%s action=%s resource=%s:%s effect=%s",
decision.Identity.Subject,
decision.Action,
decision.Resource,
decision.ObjectID,
decision.Effect)
// Send to audit system
auditSystem.LogAuthzDecision(ctx, decision)
})The AuthzDecision contains:
Action- The action that was attemptedResource- The resource typeObjectID- The resource identifierScope- The scope (if specified)Identity- The authenticated user's identityRoles- The roles assigned to the userEffect- The final decision (Allow or Deny)DefaultEffect- The default effect from the RPCReason- Human-readable reason for the decisionEvaluatedPolicies- List of policies that were checked
The audit logger is called for both allowed and denied requests, providing complete visibility.
package main
import (
"context"
"github.com/dpup/prefab"
"github.com/dpup/prefab/plugins/auth"
"github.com/dpup/prefab/plugins/authz"
)
// Define roles
const (
roleUser = authz.Role("user")
roleOwner = authz.Role("owner")
roleAdmin = authz.Role("admin")
)
// Define your domain object
type Document struct {
ID string
WorkspaceID string
OwnerID string
Published bool
}
func (d *Document) AuthzType() string { return "document" }
func (d *Document) ScopeID() string { return d.WorkspaceID }
func main() {
s := prefab.New(
prefab.WithPlugin(auth.Plugin()),
prefab.WithPlugin(authz.Plugin(
// Define policies
authz.WithPolicy(authz.Allow, roleUser, authz.Action("documents.view")),
authz.WithPolicy(authz.Allow, roleOwner, authz.Action("documents.edit")),
authz.WithPolicy(authz.Allow, roleAdmin, authz.Action("*")),
// Role hierarchy
authz.WithRoleHierarchy(roleAdmin, roleOwner, roleUser),
// Object fetcher
authz.WithObjectFetcher("document", authz.AsObjectFetcher(
authz.Fetcher(getDocument),
)),
// Role describer
authz.WithRoleDescriber("document", authz.Compose(
authz.OwnershipRole(roleOwner, func(doc *Document) string {
return doc.OwnerID
}),
authz.StaticRole(roleUser, func(_ context.Context, _ auth.Identity, doc *Document) bool {
return doc.Published
}),
)),
)),
)
// Register your service
s.RegisterService(...)
s.Start()
}
func getDocument(ctx context.Context, id string) (*Document, error) {
// Fetch from database
return db.GetDocumentByID(ctx, id)
}- Use proto annotations - Define authorization rules in proto files for clear documentation
- Use composable patterns - Leverage
Compose,OwnershipRole,MembershipRolesto eliminate boilerplate - Implement ScopedObject - Get automatic scope validation with
Compose - Use role hierarchy - Define clear role inheritance to reduce policy duplication
- Use explicit deny for blocklists - Create suspended/banned roles that override other permissions
- Test authorization - Write tests for role describers and policy evaluation
- Log authorization decisions - Enable structured logging to debug access issues
- Use the debug endpoint - Verify policies and registrations are correct
Old pattern:
builder.WithRoleDescriberFn("document", func(ctx context.Context, identity auth.Identity, object any, scope authz.Scope) ([]authz.Role, error) {
// Manual type assertion
doc, ok := object.(*Document)
if !ok {
return nil, errors.NewC("expected Document", codes.Internal)
}
// Manual scope validation
if string(scope) != doc.WorkspaceID {
return []authz.Role{}, nil
}
var roles []authz.Role
if doc.OwnerID == identity.Subject {
roles = append(roles, authz.RoleOwner)
}
return roles, nil
})New pattern:
builder.WithRoleDescriber("document", authz.Compose(
authz.OwnershipRole(authz.RoleOwner, func(doc *Document) string {
return doc.OwnerID
}),
))The new patterns eliminate:
- Manual type assertions
- Manual scope validation
- Boilerplate error handling
- Repetitive role-checking logic