Role Assignment and Authorization

Overview

User roles are now managed exclusively through the Better Auth API on the Bun backend. The Go backend is read-only with respect to roles — it only queries and enforces them, never assigns or modifies them. There are two levels of roles:
  1. Platform Roles (managed by Better Auth admin plugin)
    • Applied globally to every user
    • Values: user, admin, superadmin
    • Stored in users.role column
  2. Organization Roles (managed by Better Auth organization plugin)
    • Applied per-organization
    • Values: owner, admin, staff, viewer, member
    • Stored in member.role column per organization

How Role Assignment Works

Platform Roles (Better Auth Admin Plugin)

When a user signs up via the Bun backend (POST /api/auth/sign-up):
  • Better Auth creates an entry in the users table
  • Platform role is initially set to "user" (default)
  • Superadmin can promote users to "admin" or "superadmin" via the admin plugin API
API Endpoints (Bun Backend):
  • POST /api/auth/admin/create-user — Create user with custom platform role
  • PATCH /api/auth/admin/update-user/:userId — Update user role
  • GET /api/auth/admin/users — List all users and their roles

Organization Roles (Better Auth Organization Plugin)

When a user is invited to or joins an organization:
  • Better Auth creates an entry in the member table
  • The inviter specifies the role (owner, admin, staff, viewer, member)
  • Organization owners can update member roles via the organization plugin API
API Endpoints (Bun Backend):
  • POST /api/auth/organization/create — Create organization (caller becomes owner)
  • POST /api/auth/organization/:id/members/invite — Invite member with specific role
  • PATCH /api/auth/organization/:id/members/:userId/update-role — Change member’s role
  • DELETE /api/auth/organization/:id/members/:userId — Remove member from org

How Go Backend Queries Roles

The Go backend never writes roles. Instead, it reads them on-demand or caches them.

User Repository (Platform Role)

// internal/modules/user/adapter/outbound/persistence/postgresql/user_repository.go

// FindByID queries the users table and returns user data including platform role
user, err := userRepo.FindByID(ctx, userID)
// user.Role is "user", "admin", or "superadmin" (from users.role)

Organization Repository (Org Role)

// internal/modules/organizations/adapter/outbound/persistence/postgresql/organization_repository.go

// GetMembers joins member + users tables to return org members with their roles
members, err := orgRepo.GetMembers(ctx, organizationID)
// Each member has fields: userId, organizationId, role, joinedAt, status

Authorization with Permission Service

Instead of checking roles directly, Go modules use the PermissionService for authorization. This service:
  1. Queries the member table for a user’s org role
  2. Maps the role to a permission set (owner → full perms, staff → limited perms, etc.)
  3. Caches the result for 5 minutes

Example: Checking Leave Approval Permission

// In approval_service.go
canApprove, err := permissionSvc.CanApproveLeaveFor(ctx, approverID, employeeID, organizationID)
if !canApprove {
    return errors.New("approver not authorized")
}
This checks:
  • Both approver and employee are in the org
  • Approver has "leave:approve" permission
  • If approver is not an admin, they must be the employee’s direct manager

Permission Mappings

Org roles map to permissions as follows:
RolePermissionsUse Case
ownerAll permissionsOrganization founder/admin
adminorg:manage, member:, data:, leave:*Team lead, HR
staffdata:read, data:write, leave:requestRegular employee
viewerdata:readRead-only access
Permissions include:
  • data:read, data:write — Data access
  • leave:approve, leave:request — Leave management
  • member:invite, member:manage, member:remove — Team management
  • org:manage — Organization settings

Caching and Consistency

Permission Cache

The PermissionService caches role-to-permission lookups with a 5-minute TTL:
// 5-minute cache
cache, ok := s.cache.get("perm:" + userID + ":" + organizationID)
if !ok {
    // Cache miss: query member table, resolve permissions, cache result
}

Real-time Consistency (NATS)

To achieve real-time consistency despite caching, the Bun backend publishes role-change events to NATS. The Go backend’s MemberRoleChangeConsumer subscribes to these and invalidates the cache immediately:
  1. Member Role Changed: InvalidateMemberCache(userID, orgID)
  2. Member Removed: InvalidateMemberCache(userID, orgID)
  3. Member Added: InvalidateMemberCache(userID, orgID)

Performance Optimization (Postgres Indexes)

Specialized indexes are added to the shared PostgreSQL database to ensure permission checks remain fast (sub-millisecond):
  • idx_users_role: ON users(role) — Optimizes platform permission check
  • idx_members_role: ON members(role) — Optimizes fetching members by role
  • idx_members_org_role: ON members(organization_id, role) — Primary index for role-to-permission lookups

How to Assign Roles

As a Developer

You cannot assign roles from the Go backend. All role changes must be done via the Better Auth API on the Bun backend:
  1. Add user to organization with role:
    curl -X POST http://localhost:3000/api/auth/organization/org-123/members/invite \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{
        "email": "user@example.com",
        "role": "staff"
      }'
    
  2. Update user’s organization role:
    curl -X PATCH http://localhost:3000/api/auth/organization/org-123/members/user-456/update-role \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{"role": "admin"}'
    

As a User

In the frontend, go to Settings → Members (org admin only) and:
  • Invite new members with a specific role
  • Change existing members’ roles
  • Remove members from the organization

Testing

Test: Query User Role from Go

# Get user from Go backend
curl -X GET http://localhost:8080/v1/users/user-123 \
  -H "Authorization: Bearer <jwt>"

# Response includes role from auth table
{
  "id": "user-123",
  "email": "user@example.com",
  "name": "John Doe",
  "role": "user",  // Platform role from users.role
  "organizationId": "org-123",
  "createdAt": "2025-01-15T10:00:00Z"
}

Test: Query Organization Members from Go

# Get org members from Go backend
curl -X GET http://localhost:8080/v1/organizations/me/members \
  -H "Authorization: Bearer <jwt>"

# Response includes member roles
{
  "data": [
    {
      "userId": "user-123",
      "organizationId": "org-123",
      "role": "owner",  // Org role from member.role
      "joinedAt": "2025-01-15T10:00:00Z",
      "status": "active"
    },
    {
      "userId": "user-456",
      "organizationId": "org-123",
      "role": "staff",
      "joinedAt": "2025-01-16T14:30:00Z",
      "status": "active"
    }
  ],
  "total": 2
}

Test: Authorization Check in Go

# Try to approve a leave request as a staff member (should fail)
curl -X POST http://localhost:8080/v1/leave/requests/req-789/approve \
  -H "Authorization: Bearer <staff-token>" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Approved"}'

# Response: 403 Forbidden
{
  "error": "approver not authorized to approve this request"
}

# Try again as an admin (should succeed)
curl -X POST http://localhost:8080/v1/leave/requests/req-789/approve \
  -H "Authorization: Bearer <admin-token>" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Approved"}'

# Response: 200 OK

Key Differences from Previous Architecture

AspectOldNew
Role SourceLocal users table (synced via NATS)Better Auth tables (auth, member)
Role AssignmentGo backend NATS handlerBetter Auth API (Bun backend)
AuthorizationDirect role name checks in Go codePermission Service with role-to-permission mapping
ConsistencyEvent-driven sync (eventual)Direct queries (immediate reads)
Permission MappingHardcoded in each use caseCentralized in PermissionService