Skip to main content

IAM Integration Quick Start

This guide gets you connected to IAM and making your first calls in 15 minutes.


What is IAM?

The IAM service is the source of truth for identity and access control. It manages realms, tenants, users, memberships, roles, permissions, and invitations. It does not handle authentication (login, passwords, OAuth) or issue tokens — that belongs in a separate auth service.


Connection Setup

Import the generated stubs

Add the IAM module as a dependency:

go get github.com/TopengDev/aenoxa_iam@latest

Create a gRPC connection

package main

import (
"log"

iamv1 "github.com/TopengDev/aenoxa_iam/gen/iam/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)

func main() {
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()

// Create service clients
realmClient := iamv1.NewRealmServiceClient(conn)
tenantClient := iamv1.NewTenantServiceClient(conn)
userClient := iamv1.NewUserServiceClient(conn)
membershipClient := iamv1.NewMembershipServiceClient(conn)
roleClient := iamv1.NewRoleServiceClient(conn)
invitationClient := iamv1.NewInvitationServiceClient(conn)
apiKeyClient := iamv1.NewAPIKeyServiceClient(conn) // requires bootstrap key
}

Authentication

IAM supports two authentication modes (disabled by default). When enabled, every request must include credentials.

API Key (service-to-service)

IAM supports two types of API keys:

  • Bootstrap keys — configured via the IAM_AUTH_APIKEYS environment variable. These are root-of-trust credentials with full access to all endpoints, including API key management.
  • Database-backed keys — created at runtime via the APIKeyService. These can access all endpoints except the APIKeyService itself (preventing privilege escalation).

Use API keys when your service calls IAM directly. Set the key via gRPC metadata:

import "google.golang.org/grpc/metadata"

ctx := metadata.AppendToOutgoingContext(ctx, "x-api-key", "your-api-key")
resp, err := tenantClient.CreateTenant(ctx, req)

JWT (user-context forwarding)

Use this when forwarding an end-user's identity. Pass the JWT via the authorization header:

ctx := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+jwtToken)
resp, err := tenantClient.CreateTenant(ctx, req)

JWT claims structure:

ClaimTypeDescription
substring (UUID)User ID
tenant_idstring (UUID)Tenant context
membership_idstring (UUID)Membership ID (for permission checks via CheckPermission)
emailstringUser email
scopestringSpace-separated permissions
issstringMust match configured issuer
audstring[]Must include configured audience

Create a helper that attaches auth to every outgoing call:

func withAPIKey(ctx context.Context, key string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "x-api-key", key)
}

Or use a gRPC unary interceptor for automatic injection:

func apiKeyInterceptor(key string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any,
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, "x-api-key", key)
return invoker(ctx, method, req, reply, cc, opts...)
}
}

conn, _ := grpc.NewClient("iam:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(apiKeyInterceptor("your-api-key")),
)

Walkthrough: Complete RBAC Setup

This example creates a full identity hierarchy and checks a permission.

package main

import (
"context"
"fmt"
"log"

iamv1 "github.com/TopengDev/aenoxa_iam/gen/iam/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)

func main() {
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()

ctx := context.Background()
realmClient := iamv1.NewRealmServiceClient(conn)
tenantClient := iamv1.NewTenantServiceClient(conn)
userClient := iamv1.NewUserServiceClient(conn)
membershipClient := iamv1.NewMembershipServiceClient(conn)
roleClient := iamv1.NewRoleServiceClient(conn)

// 1. Create a realm
realm, err := realmClient.CreateRealm(ctx, &iamv1.CreateRealmRequest{
Key: "saas",
Name: "SaaS Platform",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Realm: %s\n", realm.Realm.Id)

// 2. Create a tenant
tenant, err := tenantClient.CreateTenant(ctx, &iamv1.CreateTenantRequest{
RealmId: realm.Realm.Id,
Slug: "acme",
DisplayName: "Acme Corp",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Tenant: %s\n", tenant.Tenant.Id)

// 3. Create a user
email := "admin@acme.com"
user, err := userClient.CreateUser(ctx, &iamv1.CreateUserRequest{
Email: &email,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("User: %s\n", user.User.Id)

// 4. Create a membership (user joins tenant)
membership, err := membershipClient.CreateMembership(ctx, &iamv1.CreateMembershipRequest{
TenantId: tenant.Tenant.Id,
UserId: user.User.Id,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Membership: %s\n", membership.Membership.Id)

// 5. Create a role
role, err := roleClient.CreateRole(ctx, &iamv1.CreateRoleRequest{
TenantId: tenant.Tenant.Id,
Key: "admin",
Name: "Administrator",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Role: %s\n", role.Role.Id)

// 6. List permissions (seeded at DB init)
roles, err := roleClient.ListRoles(ctx, &iamv1.ListRolesRequest{
TenantId: tenant.Tenant.Id,
})
if err != nil {
log.Fatal(err)
}

// 7. Add a permission to the role
// Use a known permission key - get its ID first
// Permissions are global (not tenant-scoped) and seeded at startup:
// tenants.read, tenants.write, tenants.suspend,
// users.read, users.write, users.suspend,
// memberships.read, memberships.write, memberships.suspend,
// roles.read, roles.write, roles.assign,
// invitations.read, invitations.write, invitations.manage
_ = roles // use ListRoles or GetRole to find permission IDs

// 8. Assign role to membership
_, err = roleClient.AssignRole(ctx, &iamv1.AssignRoleRequest{
MembershipId: membership.Membership.Id,
RoleId: role.Role.Id,
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Role assigned")

// 9. Check permission
check, err := roleClient.CheckPermission(ctx, &iamv1.CheckPermissionRequest{
MembershipId: membership.Membership.Id,
PermissionKey: "tenants.read",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("tenants.read allowed: %v\n", check.Allowed)
}

Client Setup Patterns

Singleton client

Create the IAM connection once at startup and share it across your application:

type IAMClient struct {
conn *grpc.ClientConn
Realms iamv1.RealmServiceClient
Tenants iamv1.TenantServiceClient
Users iamv1.UserServiceClient
Memberships iamv1.MembershipServiceClient
Roles iamv1.RoleServiceClient
Invitations iamv1.InvitationServiceClient
APIKeys iamv1.APIKeyServiceClient // requires bootstrap key
}

func NewIAMClient(addr, apiKey string) (*IAMClient, error) {
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(apiKeyInterceptor(apiKey)),
)
if err != nil {
return nil, err
}

return &IAMClient{
conn: conn,
Realms: iamv1.NewRealmServiceClient(conn),
Tenants: iamv1.NewTenantServiceClient(conn),
Users: iamv1.NewUserServiceClient(conn),
Memberships: iamv1.NewMembershipServiceClient(conn),
Roles: iamv1.NewRoleServiceClient(conn),
Invitations: iamv1.NewInvitationServiceClient(conn),
APIKeys: iamv1.NewAPIKeyServiceClient(conn),
}, nil
}

func (c *IAMClient) Close() error {
return c.conn.Close()
}

Set deadlines

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

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

resp, err := iam.Tenants.GetTenant(ctx, &iamv1.GetTenantRequest{Id: tenantID})

Handle errors

import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

resp, err := iam.Users.CreateUser(ctx, req)
if err != nil {
st, ok := status.FromError(err)
if !ok {
return fmt.Errorf("unexpected error: %w", err)
}

switch st.Code() {
case codes.AlreadyExists:
// Safe to treat as success — resource already created
case codes.InvalidArgument:
// Bad input — fix and don't retry
case codes.NotFound:
// Referenced resource doesn't exist
case codes.ResourceExhausted:
// Rate limited — backoff and retry
case codes.Unavailable:
// Transient — retry with backoff
default:
return fmt.Errorf("IAM error: %s: %s", st.Code(), st.Message())
}
}

Next Steps