Authorization Integration Guide¶
This guide covers integrating SpiceDB authorization with your CoreForge application.
Architecture Overview¶
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ API Handler │────▶│ AuthZ Check │────▶│ SpiceDB │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ ▲
▼ │
┌──────────────────┐ ┌──────────────────┐ │
│ Identity Service │────▶│ Authz Syncer │──────────────┘
└──────────────────┘ └──────────────────┘
- API Handlers check permissions before performing operations
- Identity Services sync changes to SpiceDB via the syncer
- SpiceDB stores relationships and evaluates permissions
Setup¶
1. Initialize SpiceDB Client¶
package main
import (
"context"
"log/slog"
"github.com/grokify/coreforge/authz/spicedb"
)
func initAuthz(ctx context.Context) (*spicedb.Client, *spicedb.Provider, *spicedb.Syncer, error) {
// Create client
client, err := spicedb.NewClient(ctx, spicedb.Config{
Mode: "remote",
Endpoint: os.Getenv("SPICEDB_ENDPOINT"),
Token: os.Getenv("SPICEDB_TOKEN"),
}, slog.Default())
if err != nil {
return nil, nil, nil, err
}
// Write schema on startup (idempotent)
schema := spicedb.BaseSchema + spicedb.ResourceSchema("project")
if err := client.WriteSchema(ctx, schema); err != nil {
client.Close()
return nil, nil, nil, err
}
// Create provider and syncer
provider := spicedb.NewProvider(client)
syncer := spicedb.NewSyncer(client)
return client, provider, syncer, nil
}
2. Wire Identity Services¶
func initServices(entClient *ent.Client, syncer authz.RelationshipSyncer) {
// Organization service with authz sync
orgService := organization.NewService(
entClient,
organization.WithAuthzSyncer(syncer),
organization.WithSyncMode(authz.SyncModeStrict),
organization.WithLogger(slog.Default()),
)
// Principal service with authz sync
principalService := principal.NewService(
entClient,
principal.WithAuthzSyncer(syncer),
principal.WithSyncMode(authz.SyncModeStrict),
principal.WithLogger(slog.Default()),
)
}
Permission Checks in Handlers¶
Basic Permission Check¶
func (h *Handler) GetProject(ctx context.Context, req *GetProjectRequest) (*Project, error) {
// Get principal from context (set by auth middleware)
principal := getPrincipalFromContext(ctx)
// Check permission
canView, err := h.provider.Can(ctx, principal, "view", authz.Resource{
Type: "project",
ID: &req.ProjectID,
})
if err != nil {
return nil, fmt.Errorf("permission check failed: %w", err)
}
if !canView {
return nil, ErrForbidden
}
// Proceed with operation
return h.projectRepo.GetByID(ctx, req.ProjectID)
}
Organization-Scoped Checks¶
func (h *Handler) CreateProject(ctx context.Context, req *CreateProjectRequest) (*Project, error) {
principal := getPrincipalFromContext(ctx)
// Check if user can create in this org
canCreate, err := h.provider.CanForOrg(ctx, principal, req.OrgID, "edit", authz.Resource{
Type: "organization",
ID: &req.OrgID,
})
if err != nil {
return nil, err
}
if !canCreate {
return nil, ErrForbidden
}
// Create project
project, err := h.projectRepo.Create(ctx, req)
if err != nil {
return nil, err
}
// Sync to SpiceDB
h.syncer.AddRelationship(ctx,
spicedb.TypePrincipal, principal.ID.String(),
"owner",
"project", project.ID.String(),
)
// Link to org
h.syncer.AddRelationship(ctx,
"organization", req.OrgID.String(),
"org",
"project", project.ID.String(),
)
return project, nil
}
Multiple Permission Checks¶
// Check if user can perform ANY of the actions
canAny, err := h.provider.CanAny(ctx, principal,
[]authz.Action{"edit", "manage"},
resource,
)
// Check if user can perform ALL of the actions
canAll, err := h.provider.CanAll(ctx, principal,
[]authz.Action{"view", "edit"},
resource,
)
Filtering Resources¶
func (h *Handler) ListProjects(ctx context.Context, req *ListProjectsRequest) ([]*Project, error) {
principal := getPrincipalFromContext(ctx)
// Get all projects (from DB)
allProjects, err := h.projectRepo.ListByOrg(ctx, req.OrgID)
if err != nil {
return nil, err
}
// Convert to authz resources
resources := make([]authz.Resource, len(allProjects))
for i, p := range allProjects {
id := p.ID
resources[i] = authz.Resource{Type: "project", ID: &id}
}
// Filter to only those the user can view
allowed, err := h.provider.Filter(ctx, principal, "view", resources)
if err != nil {
return nil, err
}
// Map back to projects
allowedIDs := make(map[uuid.UUID]bool)
for _, r := range allowed {
allowedIDs[*r.ID] = true
}
var result []*Project
for _, p := range allProjects {
if allowedIDs[p.ID] {
result = append(result, p)
}
}
return result, nil
}
Authorization Middleware¶
HTTP Middleware¶
func RequirePermission(provider authz.Authorizer, resourceType string, action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
principal := getPrincipalFromContext(ctx)
resourceID := getResourceIDFromRequest(r)
can, err := provider.Can(ctx, principal, authz.Action(action), authz.Resource{
Type: authz.ResourceType(resourceType),
ID: &resourceID,
})
if err != nil {
http.Error(w, "authorization error", http.StatusInternalServerError)
return
}
if !can {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage
router.With(RequirePermission(provider, "project", "edit")).
Put("/projects/{id}", updateProjectHandler)
Role-Based Shortcuts¶
func (h *Handler) requireOrgAdmin(ctx context.Context, orgID uuid.UUID) error {
principal := getPrincipalFromContext(ctx)
role, err := h.provider.GetRole(ctx, principal, orgID)
if err != nil {
return err
}
if role != spicedb.RelOwner && role != spicedb.RelAdmin {
return ErrForbidden
}
return nil
}
Common Patterns¶
Signup Flow¶
func (s *SignupService) Signup(ctx context.Context, input SignupInput) (*User, error) {
// Create principal (synced to SpiceDB automatically)
principal, err := s.principalService.CreateHuman(ctx, principal.CreateHumanInput{
Email: input.Email,
DisplayName: input.DisplayName,
})
if err != nil {
return nil, err
}
// Create personal organization (synced to SpiceDB automatically)
org, err := s.orgService.CreatePersonalOrg(ctx, organization.CreatePersonalOrgInput{
Name: input.DisplayName,
Slug: generateSlug(input.Email),
OwnerPrincipalID: principal.ID,
})
if err != nil {
return nil, err
}
// User is now org owner with full permissions
return &User{Principal: principal, PersonalOrg: org}, nil
}
Invite Flow¶
func (s *InviteService) AcceptInvite(ctx context.Context, token string) error {
invite, err := s.inviteRepo.GetByToken(ctx, token)
if err != nil {
return err
}
// Add member to org (synced to SpiceDB automatically)
_, err = s.orgService.AddMember(ctx, organization.AddMemberInput{
OrganizationID: invite.OrgID,
PrincipalID: invite.InviteeID,
Role: invite.Role,
})
return err
}
Resource Deletion¶
func (h *Handler) DeleteProject(ctx context.Context, req *DeleteRequest) error {
principal := getPrincipalFromContext(ctx)
// Check delete permission
canDelete, err := h.provider.Can(ctx, principal, "delete", authz.Resource{
Type: "project",
ID: &req.ProjectID,
})
if err != nil || !canDelete {
return ErrForbidden
}
// Delete from DB
if err := h.projectRepo.Delete(ctx, req.ProjectID); err != nil {
return err
}
// Clean up SpiceDB relationships (optional - SpiceDB handles orphans gracefully)
// This is only needed if you want immediate cleanup
h.syncer.RemoveRelationship(ctx,
spicedb.TypePrincipal, principal.ID.String(),
"owner",
"project", req.ProjectID.String(),
)
return nil
}
Error Handling¶
Sync Failures¶
With SyncModeEventual, sync failures are logged but don't fail operations:
// In service code
if syncErr := s.syncer.AddOrgMembership(ctx, principalID, orgID, role); syncErr != nil {
if s.syncMode == authz.SyncModeStrict {
return nil, fmt.Errorf("authz sync failed: %w", syncErr)
}
s.logger.Warn("authz sync failed",
"principal_id", principalID,
"org_id", orgID,
"error", syncErr,
)
}
For eventual consistency, implement a retry mechanism:
// Background job to retry failed syncs
func (j *SyncRetryJob) Run(ctx context.Context) {
pending, _ := j.repo.GetPendingSyncs(ctx)
for _, sync := range pending {
if err := j.syncer.Execute(ctx, sync); err != nil {
j.logger.Error("sync retry failed", "id", sync.ID, "error", err)
continue
}
j.repo.MarkComplete(ctx, sync.ID)
}
}
Testing¶
Unit Tests with No-Op Syncer¶
func TestOrganizationService(t *testing.T) {
// Use no-op syncer for unit tests
service := organization.NewService(
entClient,
organization.WithAuthzSyncer(noop.NewSyncer()),
)
// Test service logic without SpiceDB
}
Integration Tests with Embedded SpiceDB¶
//go:build integration
func TestAuthzIntegration(t *testing.T) {
ctx := context.Background()
client, _ := spicedb.NewClient(ctx, spicedb.DefaultConfig(), nil)
defer client.Close()
client.WriteSchema(ctx, spicedb.BaseSchema)
syncer := spicedb.NewSyncer(client)
provider := spicedb.NewProvider(client)
// Test full flow
orgID := uuid.New()
ownerID := uuid.New()
syncer.RegisterOrganization(ctx, orgID, ownerID)
canManage, _ := provider.Can(ctx, authz.Principal{ID: ownerID}, "manage",
authz.Resource{Type: "organization", ID: &orgID})
assert.True(t, canManage)
}
Performance Considerations¶
- Batch Relationship Writes: Use
WriteRelationships()for multiple changes - Cache Permission Results: For frequently-checked permissions
- Use LookupResources: Instead of checking each resource individually
- Minimize Round Trips: Group permission checks where possible
Next Steps¶
- SpiceDB Setup - Deployment configuration
- SpiceDB Schema - Understanding the schema