Skip to main content

Integration Patterns & Best Practices

Practical guidance for common IAM integration scenarios.


1. RBAC Enforcement

IAM stores roles and permissions but does not enforce them on its own endpoints. Your service is responsible for checking permissions before executing protected operations.

Basic pattern

func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) error {
// 1. Get the membership ID from the authenticated user's context
membershipID := auth.MembershipIDFromContext(ctx)

// 2. Check permission
check, err := s.iam.Roles.CheckPermission(ctx, &iamv1.CheckPermissionRequest{
MembershipId: membershipID,
PermissionKey: "orders.write",
})
if err != nil {
return fmt.Errorf("check permission: %w", err)
}
if !check.Allowed {
return status.Error(codes.PermissionDenied, "insufficient permissions")
}

// 3. Proceed with the operation
return s.createOrder(ctx, req)
}

Cache permission checks

CheckPermission hits the database every time. For latency-sensitive paths, cache the result:

type PermissionCache struct {
cache *lru.Cache // or sync.Map, Redis, etc.
ttl time.Duration
iam iamv1.RoleServiceClient
}

func (c *PermissionCache) Check(ctx context.Context, membershipID, permKey string) (bool, error) {
cacheKey := membershipID + ":" + permKey
if val, ok := c.cache.Get(cacheKey); ok {
return val.(bool), nil
}

resp, err := c.iam.CheckPermission(ctx, &iamv1.CheckPermissionRequest{
MembershipId: membershipID,
PermissionKey: permKey,
})
if err != nil {
return false, err
}

c.cache.SetWithTTL(cacheKey, resp.Allowed, c.ttl)
return resp.Allowed, nil
}

Recommended TTL: 30-60 seconds. Invalidate on user.role.assigned and user.role.unassigned events (see Event Catalog).

Invalidate on role changes

Subscribe to role assignment events and clear the cache:

func (c *PermissionCache) HandleEvent(eventType string, payload map[string]string) {
switch eventType {
case "user.role.assigned", "user.role.unassigned":
membershipID := payload["membership_id"]
c.cache.DeletePrefix(membershipID + ":")
}
}

2. User Onboarding Flow

A typical flow for onboarding a user into a tenant:

Your Service                          IAM
│ │
│ 1. CreateUser(email) │
│ ────────────────────────────────>│
│ ← user.id │
│ │
│ 2. CreateInvitation(tenant, email)│
│ ────────────────────────────────>│
│ ← invitation (email sent) │
│ │
│ ... user clicks link ... │
│ │
│ 3. AcceptInvitation(token, user) │
│ ────────────────────────────────>│
│ ← membership created │
│ │
│ 4. AssignRole(membership, role) │
│ ────────────────────────────────>│
│ ← role assigned │

Step details:

StepWho does itNotes
CreateUserYour backendIf user already exists (same email), you get the existing user back
CreateInvitationYour backendIAM's email worker sends the invite email via Resend (if enabled)
AcceptInvitationYour backend (on behalf of user)Requires the invitation token. Creates the membership automatically
AssignRoleYour backendAssign default roles. Roles are tenant-scoped, so create them first if needed

Alternative (skip invitation): If you don't need the invitation flow, create the membership directly:

// CreateUser + CreateMembership + AssignRole
user, _ := iam.Users.CreateUser(ctx, &iamv1.CreateUserRequest{Email: &email})
membership, _ := iam.Memberships.CreateMembership(ctx, &iamv1.CreateMembershipRequest{
TenantId: tenantID,
UserId: user.User.Id,
})
iam.Roles.AssignRole(ctx, &iamv1.AssignRoleRequest{
MembershipId: membership.Membership.Id,
RoleId: adminRoleID,
})

3. Multi-Tenant Data Isolation

IAM provides tenant boundaries. Your service enforces them in its own data.

Use tenant_id as a foreign key

Every table in your service that holds tenant-scoped data should include a tenant_id column:

CREATE TABLE orders (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL, -- from IAM
...
);

CREATE INDEX ix_orders_tenant ON orders(tenant_id);

Always scope queries

Never query without a tenant filter:

// Good
rows, _ := db.Query("SELECT * FROM orders WHERE tenant_id = $1", tenantID)

// Bad — leaks data across tenants
rows, _ := db.Query("SELECT * FROM orders")

Resolve tenant from membership

When a request comes in with a user identity, resolve their tenant through IAM:

// Get user's memberships
memberships, _ := iam.Memberships.ListUserMemberships(ctx, &iamv1.ListUserMembershipsRequest{
UserId: userID,
})

// The tenant_id on the membership tells you which tenant the user is acting in
tenantID := memberships.Memberships[0].TenantId

React to tenant suspension

Listen for tenant.suspended events and block access:

case "tenant.suspended":
tenantID := payload["tenant_id"]
// Block all operations for this tenant
s.tenantBlocklist.Add(tenantID)

case "tenant.reactivated":
tenantID := payload["tenant_id"]
s.tenantBlocklist.Remove(tenantID)

4. Error Handling

Every IAM endpoint returns standard gRPC status codes. Handle them consistently.

CodeMeaningAction
OKSuccessProceed normally
NOT_FOUNDResource doesn't existDon't retry. Check the ID is correct.
ALREADY_EXISTSDuplicate resourceSafe to treat as success. IAM returns the existing resource for idempotent creates.
INVALID_ARGUMENTBad inputDon't retry. Fix the input (invalid email, empty required field, page_size > 100).
FAILED_PRECONDITIONInvalid state or missing referenceDon't retry. Check: is the entity in the right status? Does the referenced FK exist?
RESOURCE_EXHAUSTEDRate limitedRetry with backoff. IAM applies a global rate limit.
UNAUTHENTICATEDBad or missing credentialsRefresh your API key or JWT.
UNAVAILABLETransient failureRetry with backoff.
INTERNALServer errorLog and alert. Don't retry immediately.

Distinguish retryable from non-retryable

func isRetryable(err error) bool {
st, ok := status.FromError(err)
if !ok {
return false
}
switch st.Code() {
case codes.Unavailable, codes.ResourceExhausted:
return true
default:
return false
}
}

5. Retry Strategy

Set deadlines

Always set a context deadline. IAM applies a default 10s timeout, but your service should control its own:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

Exponential backoff

For retryable errors, use exponential backoff with jitter:

func callWithRetry(ctx context.Context, fn func(context.Context) error) error {
backoff := 100 * time.Millisecond
maxBackoff := 5 * time.Second

for attempt := 0; attempt < 3; attempt++ {
err := fn(ctx)
if err == nil {
return nil
}
if !isRetryable(err) {
return err
}

jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
select {
case <-time.After(backoff + jitter):
backoff = min(backoff*2, maxBackoff)
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("max retries exceeded")
}

What NOT to retry

CodeWhy not
INVALID_ARGUMENTInput is wrong. Retrying sends the same bad data.
NOT_FOUNDResource doesn't exist. Won't appear by retrying.
FAILED_PRECONDITIONState is wrong. Requires a different action to fix.
ALREADY_EXISTSNot an error — treat as success.

6. Reacting to Events

Subscribe to IAM events to keep your service in sync. See Event Catalog for the full list.

Events your service should handle

EventWhat to do
tenant.suspendedBlock all access for the tenant. Reject new requests.
tenant.reactivatedRestore access for the tenant.
membership.suspendedRevoke user's active sessions for that tenant.
membership.reactivatedAllow user to access the tenant again.
user.role.assignedInvalidate permission cache for this membership.
user.role.unassignedInvalidate permission cache for this membership.
user.suspendedTerminate all active sessions for this user across all tenants.
user.reactivatedAllow user to log in again.

Events you can usually ignore

EventWhy
realm.createdRealms are administrative. Your service rarely needs to react.
permission.createdPermissions are seeded at startup. Rarely changes at runtime.
role.createdOnly relevant if you cache role metadata.
user.invitedThe email worker handles sending the invitation email.
invitation.acceptedReact to membership.created instead — that's the meaningful state change.
invitation.revokedNo action needed unless you show pending invitations in your UI.

Consumer setup

See Event Catalog for the full consumer setup code. The key points:

  1. Declare a durable queue with a name unique to your service
  2. Bind to the iam.events exchange with routing patterns matching the events you care about
  3. Use MessageId (event UUID) to deduplicate — IAM guarantees at-least-once delivery
  4. Acknowledge only after you've processed the event successfully

Next Steps