Permission Service Integration

Overview

The PermissionService is a centralized authorization service that:
  1. Queries user roles from Better Auth PostgreSQL tables
  2. Maps roles to permissions (e.g., owner["org:manage", "data:write", "leave:approve", ...])
  3. Caches permission lookups for 5 minutes
  4. Provides methods to check authorization in your use cases
Use it whenever you need to verify if a user is allowed to perform an action.

Interface

The PermissionService implements the port.PermissionService interface:
// internal/modules/organizations/application/port/permission_service.go

type PermissionService interface {
    // GetUserPlatformRole returns the global role (user, admin, superadmin)
    GetUserPlatformRole(ctx context.Context, userID string) (string, error)

    // IsUserSuperAdmin checks if user has superadmin platform role
    IsUserSuperAdmin(ctx context.Context, userID string) (bool, error)

    // GetUserOrgRole returns the user's role in a specific organization
    GetUserOrgRole(ctx context.Context, userID, organizationID string) (string, error)

    // CanManageOrg checks if user can manage the organization
    CanManageOrg(ctx context.Context, userID, organizationID string) (bool, error)

    // GetUserPermissions returns all permissions for a user in an org
    GetUserPermissions(ctx context.Context, userID, organizationID string) ([]string, error)

    // HasPermission checks if user has a specific permission in an org
    HasPermission(ctx context.Context, userID, organizationID, permission string) (bool, error)

    // CanApproveLeaveFor checks if user can approve leave for an employee
    CanApproveLeaveFor(ctx context.Context, approverID, employeeID, organizationID string) (bool, error)

    // CanUserApproveLease checks if user can approve lease (placeholder)
    CanUserApproveLease(ctx context.Context, userID, organizationID string) (bool, error)
}

Usage Patterns

Pattern 1: Check Specific Permission

When: You need to verify a single permission like “data:write” or “leave:approve”
// In your use case or handler
func (s *MyService) UpdateData(ctx context.Context, userID, orgID, dataID string, updates *Data) error {
    // Check permission
    hasPermission, err := s.permissionSvc.HasPermission(ctx, userID, orgID, "data:write")
    if err != nil {
        return fmt.Errorf("failed to check permission: %w", err)
    }
    if !hasPermission {
        return errors.New("user not authorized to update data")
    }

    // Proceed with update
    return s.dataRepo.Update(ctx, dataID, updates)
}

Pattern 2: Check Role

When: You need to know what role the user has (for role-specific logic or UI rendering)
func (s *MyService) GetUserOrgContext(ctx context.Context, userID, orgID string) (*UserOrgContext, error) {
    role, err := s.permissionSvc.GetUserOrgRole(ctx, userID, orgID)
    if err != nil {
        return nil, err
    }

    canManage, err := s.permissionSvc.CanManageOrg(ctx, userID, orgID)
    if err != nil {
        return nil, err
    }

    return &UserOrgContext{
        UserID:        userID,
        OrganizationID: orgID,
        Role:          role,
        CanManage:     canManage,
    }, nil
}

Pattern 3: Check Multiple Permissions (Any)

When: User needs ANY of several permissions to proceed
func (s *MyService) CanExportData(ctx context.Context, userID, orgID string) (bool, error) {
    permissions, err := s.permissionSvc.GetUserPermissions(ctx, userID, orgID)
    if err != nil {
        return false, err
    }

    // Check if user has any export permission
    for _, perm := range permissions {
        if strings.HasPrefix(perm, "data:") || strings.HasPrefix(perm, "report:") {
            return true, nil
        }
    }
    return false, nil
}

Pattern 4: Leave Approval (Pre-Built Method)

When: Checking if user can approve leave for an employee
// In approval_service.go (Leave module)
func (s *ApprovalService) ApproveRequest(ctx context.Context, requestID, approverID string) error {
    request, _ := s.leaveRequestRepo.FindByID(ctx, requestID)

    // Use the specialized leave-approval check
    canApprove, err := s.permissionSvc.CanApproveLeaveFor(
        ctx,
        approverID,
        request.UserID,
        request.OrganizationID,
    )
    if err != nil {
        return err
    }
    if !canApprove {
        return errors.New("not authorized")
    }

    // Proceed with approval
    return s.leaveRequestRepo.Update(ctx, request)
}

Integration with Dependency Injection

In Module Initialization

// internal/modules/mymodule/module.go

import (
    orgModule "github.com/waynecheah/go-core/internal/modules/organizations"
)

func NewModule(c *app.Container, orgModule *organizations.Module) *Module {
    // ... other initialization

    // Get PermissionService from organizations module
    myService := usecase.NewMyService(
        myRepo,
        orgModule.Permissions,  // <-- Use this!
    )

    return &Module{
        Service: myService,
    }
}

In Your Use Case

// internal/modules/mymodule/application/usecase/my_service.go

type MyService struct {
    repo              port.MyRepository
    permissionSvc     orgPort.PermissionService  // <-- Inject this
}

func NewMyService(
    repo port.MyRepository,
    permissionSvc orgPort.PermissionService,
) *MyService {
    return &MyService{
        repo:          repo,
        permissionSvc: permissionSvc,
    }
}

func (s *MyService) DoSomething(ctx context.Context, userID, orgID string) error {
    canDo, err := s.permissionSvc.HasPermission(ctx, userID, orgID, "my:permission")
    if err != nil {
        return err
    }
    if !canDo {
        return errors.New("not authorized")
    }
    // ... proceed
}

Caching Behavior

Cache Details

  • Key: perm:<userId>:<organizationId>
  • TTL: 5 minutes
  • Stored: User’s org role + resolved permissions array

How It Works

First request:
User→PermissionSvc.HasPermission("user1", "org1", "leave:approve")
  ├─ Cache miss (no entry)
  ├─ Query: SELECT role FROM member WHERE userId='user1' AND organizationId='org1'
  ├─ Result: role="admin"
  ├─ Resolve: admin → ["data:read", "data:write", "leave:approve", ...]
  ├─ Cache: store {"role": "admin", "permissions": [...]}, TTL=5min
  └─ Return: true (contains "leave:approve")
Second request (within 5 minutes):
User→PermissionSvc.HasPermission("user1", "org1", "data:write")
  ├─ Cache hit!
  ├─ Return: true (contains "data:write")
After 5 minutes:
User→PermissionSvc.HasPermission("user1", "org1", "leave:approve")
  ├─ Cache expired
  ├─ Query: SELECT role FROM member WHERE userId='user1' AND organizationId='org1'
  ├─ ... repeat above ...

Performance Impact

  • Cached: ~1-2 µs (map lookup)
  • Uncached: ~10-50 ms (DB query + resolution)
This is why caching is important — a single approval operation might check permissions 3-4 times.

Error Handling

Common Errors

// User not found in organization
role, err := s.permissionSvc.GetUserOrgRole(ctx, "nonexistent-user", "org1")
// err might be: "user not found in organization" or ErrUserNotInOrg

// Database error
perms, err := s.permissionSvc.GetUserPermissions(ctx, "user1", "org1")
// err might be: "failed to query database: connection timeout"

// Always check err!
if err != nil {
    return fmt.Errorf("permission check failed: %w", err)
}

Defensive Coding

Always treat missing users as “not authorized”:
canApprove, err := s.permissionSvc.CanApproveLeaveFor(ctx, approverID, employeeID, orgID)
if err != nil {
    // Log the error but fail securely
    s.log.Error("permission check failed", zap.Error(err))
    return errors.New("authorization failed (please try again)")
}
if !canApprove {
    return errors.New("not authorized")
}

Permission Reference

Available Permissions

PermissionRoleMeaning
data:readstaff, member, viewerRead data/records
data:writeowner, admin, staffWrite/edit data
leave:requestowner, admin, staff, memberRequest leave
leave:approveowner, adminApprove leave requests
member:inviteowner, adminInvite members
member:manageowner, adminUpdate member roles
member:removeowner, adminRemove members
org:manageowner, adminManage org settings
report:viewowner, adminView reports
report:exportowner, adminExport reports

Role-to-Permission Mapping

// internal/modules/organizations/application/usecase/permission_service.go
var defaultRolePermissions = map[string][]string{
    "owner": {
        "data:read", "data:write",
        "leave:approve", "leave:request",
        "member:invite", "member:manage", "member:remove",
        "org:manage",
    },
    "admin": {
        "data:read", "data:write",
        "leave:approve", "leave:request",
        "member:invite", "member:manage", "member:remove",
        "org:manage",
    },
    "staff": {
        "data:read", "data:write",
        "leave:request",
    },
    "member": {
        "data:read", "data:write",
        "leave:request",
    },
    "viewer": {
        "data:read",
    },
}

Middleware Integration

Auto-Extracting Organization from JWT

The request context contains organization_id from the JWT. Use it:
// In your handler
func (h *MyHandler) MyEndpoint(c echo.Context) error {
    userID := c.Get("user_id").(string)
    orgID := c.Get("organization_id").(string)  // From JWT context

    canDo, err := h.service.CheckPermission(c.Request().Context(), userID, orgID, "my:perm")
    if err != nil || !canDo {
        return echo.NewHTTPError(http.StatusForbidden, "Not authorized")
    }

    // Proceed
}

Testing

Unit Test Example

// internal/modules/mymodule/application/usecase/my_service_test.go

func TestMyService_DoSomething_NotAuthorized(t *testing.T) {
    mockPermissionSvc := &mockPermissionService{}
    mockPermissionSvc.On("HasPermission",
        mock.Anything,
        "user1",
        "org1",
        "my:permission",
    ).Return(false, nil)

    svc := NewMyService(mockRepo, mockPermissionSvc)

    err := svc.DoSomething(context.Background(), "user1", "org1")
    assert.Error(t, err)
    assert.Equal(t, "not authorized", err.Error())
}

func TestMyService_DoSomething_Authorized(t *testing.T) {
    mockPermissionSvc := &mockPermissionService{}
    mockPermissionSvc.On("HasPermission",
        mock.Anything,
        "user1",
        "org1",
        "my:permission",
    ).Return(true, nil)

    svc := NewMyService(mockRepo, mockPermissionSvc)

    err := svc.DoSomething(context.Background(), "user1", "org1")
    assert.NoError(t, err)
}

FAQ

Q: Can I change permission mappings?

A: The mappings are hardcoded in permission_service.go (the defaultRolePermissions map). To change them, edit this file and redeploy. This is intentional — permissions are part of system design, not runtime configuration.

Q: What if I need real-time permission updates (< 5 sec)?

A: Options:
  1. Use shorter TTL: Change cache TTL from 5 min to 1 min in NewPermissionService()
  2. Subscribe to events: Have Bun publish a NATS event when role changes, Go listens and invalidates cache
  3. Disable cache: Return false from cache.get() to force DB queries (not recommended for performance)

Q: Can I add new permissions?

A: Yes:
  1. Add the permission string to the appropriate role in defaultRolePermissions
  2. Use it in your service: HasPermission(ctx, user, org, "my:new:permission")
  3. Test it

Q: What about superadmins?

A: Platform superadmins (from auth.role = "superadmin") can do anything. Check with IsUserSuperAdmin(ctx, userID) if needed.