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:
| Step | Who does it | Notes |
|---|---|---|
| CreateUser | Your backend | If user already exists (same email), you get the existing user back |
| CreateInvitation | Your backend | IAM's email worker sends the invite email via Resend (if enabled) |
| AcceptInvitation | Your backend (on behalf of user) | Requires the invitation token. Creates the membership automatically |
| AssignRole | Your backend | Assign 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.
| Code | Meaning | Action |
|---|---|---|
OK | Success | Proceed normally |
NOT_FOUND | Resource doesn't exist | Don't retry. Check the ID is correct. |
ALREADY_EXISTS | Duplicate resource | Safe to treat as success. IAM returns the existing resource for idempotent creates. |
INVALID_ARGUMENT | Bad input | Don't retry. Fix the input (invalid email, empty required field, page_size > 100). |
FAILED_PRECONDITION | Invalid state or missing reference | Don't retry. Check: is the entity in the right status? Does the referenced FK exist? |
RESOURCE_EXHAUSTED | Rate limited | Retry with backoff. IAM applies a global rate limit. |
UNAUTHENTICATED | Bad or missing credentials | Refresh your API key or JWT. |
UNAVAILABLE | Transient failure | Retry with backoff. |
INTERNAL | Server error | Log 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
| Code | Why not |
|---|---|
INVALID_ARGUMENT | Input is wrong. Retrying sends the same bad data. |
NOT_FOUND | Resource doesn't exist. Won't appear by retrying. |
FAILED_PRECONDITION | State is wrong. Requires a different action to fix. |
ALREADY_EXISTS | Not 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
| Event | What to do |
|---|---|
tenant.suspended | Block all access for the tenant. Reject new requests. |
tenant.reactivated | Restore access for the tenant. |
membership.suspended | Revoke user's active sessions for that tenant. |
membership.reactivated | Allow user to access the tenant again. |
user.role.assigned | Invalidate permission cache for this membership. |
user.role.unassigned | Invalidate permission cache for this membership. |
user.suspended | Terminate all active sessions for this user across all tenants. |
user.reactivated | Allow user to log in again. |
Events you can usually ignore
| Event | Why |
|---|---|
realm.created | Realms are administrative. Your service rarely needs to react. |
permission.created | Permissions are seeded at startup. Rarely changes at runtime. |
role.created | Only relevant if you cache role metadata. |
user.invited | The email worker handles sending the invitation email. |
invitation.accepted | React to membership.created instead — that's the meaningful state change. |
invitation.revoked | No action needed unless you show pending invitations in your UI. |
Consumer setup
See Event Catalog for the full consumer setup code. The key points:
- Declare a durable queue with a name unique to your service
- Bind to the
iam.eventsexchange with routing patterns matching the events you care about - Use
MessageId(event UUID) to deduplicate — IAM guarantees at-least-once delivery - Acknowledge only after you've processed the event successfully
Next Steps
- Getting Started — Connection setup and first calls
- API Reference — Complete endpoint documentation
- Event Catalog — All events with payloads and consumer setup