Skip to content

Permissions

The Permission system in Comby provides a flexible and extensible authorization framework for controlling access to commands, queries, runtime operations, and store operations. It implements a role-based access control (RBAC) system with support for custom authorization logic when needed.

Permission System Overview

Permissions are automatically registered by the framework for all commands and queries. The default RBAC system is sufficient for most use cases, where permissions are assigned to groups and identities inherit permissions through their group memberships. However, domains can override these default permissions with custom authorization logic when needed.

Key Concepts

  • Automatic Registration: All commands and queries automatically get permission entries
  • RBAC by Default: The standard role-based access control system handles most authorization needs
  • Custom Overrides: Domains can provide custom permission functions for special cases
  • Group-based Authorization: Permissions are assigned to groups, and identities inherit permissions through group membership

Permission Hierarchy

Comby implements three levels of permission requirements, forming a hierarchy from least to most restrictive:

1. Anonymous

No authentication required. Operations are completely public and accessible to anyone.

Required context: None Use cases: Public endpoints, health checks, public documentation

2. Authenticated

User must be logged in with a valid account and session, but does not need an active identity.

Required context: Account UUID + Session UUID Use cases: Account management, profile operations, listing available tenants

3. Authorized

User must be logged in AND have an active identity selected within a tenant context.

Required context: Tenant UUID + Identity UUID + Account UUID + Session UUID Use cases: Business operations within a tenant, resource management

Identity, Tenant, and Group Model

Understanding the relationship between these entities is crucial for implementing permissions correctly:

  • An Identity can have multiple Groups assigned
  • Each Group belongs to exactly one Tenant
  • Each Group can have multiple Permissions assigned
  • Authorized operations require: tenant + identity + account + session context

Permission Types

The permission system supports four types of operations:

Command Permissions

Control access to state-changing operations (write side).

go
// Permission factory functions
NewPermissionCmd(domain, commandData, description)
NewPermissionCmdFunc(domain, commandData, description, fn)

Query Permissions

Control access to data retrieval operations (read side).

go
// Permission factory functions
NewPermissionQry(domain, queryData, description)
NewPermissionQryFunc(domain, queryData, description, fn)

Runtime Permissions

Control access to runtime-level operations.

go
// Permission factory functions
NewPermissionRuntime(domain, runtimeType, description)
NewPermissionRuntimeFunc(domain, runtimeType, description, fn)

Store Permissions

Control access to store-level operations.

go
// Permission factory functions
NewPermissionStore(domain, storeType, description)
NewPermissionStoreFunc(domain, storeType, description, fn)

Default RBAC Behavior

For most domains, the default RBAC system is sufficient. Commands and queries are automatically assigned permissions that work with the group-based authorization system:

  • System-admin group members can perform any operation
  • Users with an identity belonging to a group that has the specific permission enabled can perform the operation
  • The framework handles all permission checking automatically

Registering Permissions

Normal Commands and Queries

For standard business logic that uses the default RBAC system, you typically don't need to explicitly register permissions. The framework handles this automatically. However, you can register permissions to provide human-readable descriptions:

go
package order

import (
	"context"
	"github.com/gradientzero/comby/v2"
	"github.com/gradientzero/comby/v2/domain/order/aggregate"
	"github.com/gradientzero/comby/v2/domain/order/command"
	"github.com/gradientzero/comby/v2/domain/order/query"
	"github.com/gradientzero/comby/v2/domain/runtime"
)

func Register(ctx context.Context, fc *comby.Facade) error {
	var domain = aggregate.NewAggregate().GetDomain()

	// Register aggregate, handlers, etc.
	comby.RegisterAggregate(fc, aggregate.NewAggregate)
	// ... other registrations ...

	// Register permissions with descriptions
	runtime.RegisterPermission(
		// Command permissions
		runtime.NewPermissionCmd(
			domain,
			&command.CreateOrder{},
			"Create a new order",
		),
		runtime.NewPermissionCmd(
			domain,
			&command.UpdateOrder{},
			"Update an existing order",
		),
		runtime.NewPermissionCmd(
			domain,
			&command.CancelOrder{},
			"Cancel an order",
		),

		// Query permissions
		runtime.NewPermissionQry(
			domain,
			&query.GetOrderById{},
			"Retrieve order by ID",
		),
		runtime.NewPermissionQry(
			domain,
			&query.ListOrders{},
			"List all orders",
		),
	)

	return nil
}

These permissions will use the default RBAC system:

  • System admins can perform these operations
  • Users with identities belonging to groups that have these permissions enabled can perform them
  • Authorization is handled automatically by the framework

Custom Permission Functions

When you need custom authorization logic beyond the standard RBAC system, use the Func variants. This is common for domains like account, group, and identity that require special permission checks.

Example: Identity Domain with Custom Permissions

go
package identity

import (
	"context"
	"fmt"
	"github.com/gradientzero/comby/v2"
	"github.com/gradientzero/comby/v2/domain/identity/aggregate"
	"github.com/gradientzero/comby/v2/domain/identity/command"
	"github.com/gradientzero/comby/v2/domain/runtime"
)

func Register(ctx context.Context, fc *comby.Facade) error {
	var domain = aggregate.NewAggregate().GetDomain()

	// Register aggregate, handlers, etc.
	comby.RegisterAggregate(fc, aggregate.NewAggregate)
	// ... other registrations ...

	// Register custom permissions with validation functions
	runtime.RegisterPermission(
		runtime.NewPermissionCmdFunc(
			domain,
			&command.IdentityCommandAddToken{},
			"Add token for any other identity",
			customPermissionSameIdentity,
		),
		runtime.NewPermissionCmdFunc(
			domain,
			&command.IdentityCommandRemoveToken{},
			"Remove token of any other identity",
			customPermissionSameIdentity,
		),
		runtime.NewPermissionCmdFunc(
			domain,
			&command.IdentityCommandUpdateProfile{},
			"Update profile of any other identity",
			customPermissionSameIdentity,
		),
	)

	return nil
}

// Custom permission function that checks if user is modifying their own identity
var customPermissionSameIdentity runtime.PermissionCommandFunc = func(
	ctx context.Context,
	cmd comby.Command,
) error {
	// This custom function allows:
	// - identities belonging to system-admin group
	// - identities belonging to a group with this permission enabled
	// - users modifying their own identity (checked below)

	// Extract the target identity UUID from the command
	var targetIdentityUuid string
	switch c := cmd.GetDomainCmd().(type) {
	case *command.IdentityCommandAddToken:
		targetIdentityUuid = c.IdentityUuid
	case *command.IdentityCommandRemoveToken:
		targetIdentityUuid = c.IdentityUuid
	case *command.IdentityCommandUpdateProfile:
		targetIdentityUuid = c.IdentityUuid
	default:
		return fmt.Errorf("%s could not cast command to concrete type", cmd)
	}

	// Get request context
	var reqCtx *comby.RequestContext = cmd.GetReqCtx()

	// Allow users to modify their own identity
	// Disallow modifying other identities (unless they have the permission via RBAC)
	if reqCtx.SenderIdentityUuid != targetIdentityUuid {
		return runtime.ErrPermissionDenied
	}

	// Allow operation
	return nil
}

Example: Account Domain with Mixed Permissions

go
package account

import (
	"context"
	"github.com/gradientzero/comby/v2"
	"github.com/gradientzero/comby/v2/domain/account/aggregate"
	"github.com/gradientzero/comby/v2/domain/account/command"
	"github.com/gradientzero/comby/v2/domain/runtime"
)

func Register(ctx context.Context, fc *comby.Facade) error {
	var domain = aggregate.NewAggregate().GetDomain()

	comby.RegisterAggregate(fc, aggregate.NewAggregate)
	// ... other registrations ...

	runtime.RegisterPermission(
		// Standard RBAC permission
		runtime.NewPermissionCmd(
			domain,
			&command.AccountCommandActivate{},
			"Activate an existing account",
		),

		// Custom permission with specific authorization logic
		runtime.NewPermissionCmdFunc(
			domain,
			&command.AccountCommandPasswordReset{},
			"Reset password of an existing account",
			func(ctx context.Context, cmd comby.Command) error {
				// Following can perform this operation:
				// - identity belonging to system-admin group
				// - identity belonging to a group with this permission enabled

				// Add custom logic here if needed
				// For example, check if user is resetting their own password

				return runtime.AuthorizedCmdFn(ctx, cmd)
			},
		),
	)

	return nil
}

Using Built-in Permission Functions

Comby provides pre-built permission functions for each hierarchy level:

go
// Command permission functions
runtime.AnonymousCmdFn    // No authentication required
runtime.AuthenticatedCmdFn // Requires account + session
runtime.AuthorizedCmdFn    // Requires tenant + identity + account + session

// Query permission functions
runtime.AnonymousQryFn
runtime.AuthenticatedQryFn
runtime.AuthorizedQryFn

// Runtime permission functions
runtime.AnonymousRuntimeFn
runtime.AuthenticatedRuntimeFn
runtime.AuthorizedRuntimeFn

Example using built-in functions:

go
runtime.RegisterPermission(
	// Public command, no authentication needed
	runtime.NewPermissionCmdFunc(
		domain,
		&command.PublicCommand{},
		"Public operation",
		runtime.AnonymousCmdFn,
	),

	// Requires authentication only
	runtime.NewPermissionCmdFunc(
		domain,
		&command.UserCommand{},
		"User operation",
		runtime.AuthenticatedCmdFn,
	),

	// Requires full authorization
	runtime.NewPermissionCmdFunc(
		domain,
		&command.TenantCommand{},
		"Tenant operation",
		runtime.AuthorizedCmdFn,
	),
)

Permissions for Projections

Projections are read-only views built from aggregates. They use the standard query permission system since they serve data in response to queries.

Example: Order Projection with Permissions

go
package order

import (
	"context"
	"github.com/gradientzero/comby/v2"
	"github.com/gradientzero/comby/v2/domain/order/aggregate"
	"github.com/gradientzero/comby/v2/domain/order/query"
	"github.com/gradientzero/comby/v2/domain/runtime"
)

func Register(ctx context.Context, fc *comby.Facade) error {
	var domain = aggregate.NewAggregate().GetDomain()

	// Register aggregate
	comby.RegisterAggregate(fc, aggregate.NewAggregate)

	// Create and register projection
	projRm := comby.NewProjectionAggregate(
		fc,
		aggregate.NewAggregate,
	)
	comby.RegisterEventHandler(fc, projRm)

	// Register query handler for the projection
	projQh := comby.NewProjectionQueryHandler(projRm)
	comby.RegisterQueryHandler(fc, projQh)

	// Register permissions for projection queries
	runtime.RegisterPermission(
		// Standard projection queries use normal query permissions
		runtime.NewPermissionQry(
			domain,
			&comby.ProjectionQueryGetRequest[*aggregate.Order]{},
			"Retrieve order projection by ID",
		),
		runtime.NewPermissionQry(
			domain,
			&comby.ProjectionQueryListRequest[*aggregate.Order]{},
			"List all order projections",
		),

		// You can also use custom functions if needed
		runtime.NewPermissionQryFunc(
			domain,
			&query.ProjectionQueryGetSensitive{},
			"Retrieve sensitive order data",
			func(ctx context.Context, qry comby.Query) error {
				// Custom logic for sensitive data access
				// E.g., only allow certain roles or groups

				return runtime.AuthorizedQryFn(ctx, qry)
			},
		),
	)

	return nil
}

Projection queries work exactly like regular queries from a permission perspective:

  • They can use default RBAC (most common)
  • They can use custom permission functions when needed
  • They follow the same three-level hierarchy (Anonymous, Authenticated, Authorized)

Permission Errors

The permission system uses two main error types:

go
// User is not authenticated (no valid session)
runtime.ErrUnauthorized

// User is authenticated but doesn't have permission
runtime.ErrPermissionDenied

Use these errors in custom permission functions to properly communicate authorization failures:

go
var customPermission runtime.PermissionCommandFunc = func(
	ctx context.Context,
	cmd comby.Command,
) error {
	reqCtx := cmd.GetReqCtx()

	// Check authentication first
	if reqCtx.SenderAccountUuid == "" {
		return runtime.ErrUnauthorized
	}

	// Check authorization
	if !hasPermission(reqCtx) {
		return runtime.ErrPermissionDenied
	}

	return nil
}

Best Practices

  1. Use Default RBAC When Possible: Most domains don't need custom permission logic. Let the framework handle authorization through groups.

  2. Provide Clear Descriptions: Always include human-readable descriptions when registering permissions. These help administrators understand what permissions control.

  3. Keep Permission Logic Simple: Custom permission functions should be straightforward and easy to understand. Complex authorization logic is a sign that your domain model may need adjustment.

  4. Document Custom Permissions: When you do use custom permission functions, document why they're needed and what special logic they implement.

  5. Test Permission Functions: Write unit tests for custom permission functions, especially those with complex logic.

  6. Consider the Hierarchy: Choose the appropriate permission level (Anonymous, Authenticated, Authorized) based on the actual requirements of your operation.

Summary

The Comby permission system provides:

  • Automatic registration of permissions for all commands and queries
  • Default RBAC handling for most use cases
  • Custom permission functions for special authorization requirements
  • Three-level hierarchy (Anonymous, Authenticated, Authorized)
  • Four operation types (Command, Query, Runtime, Store)
  • Group-based authorization through identity-group-permission relationships

For most domains, simply registering your commands and queries is sufficient. The framework handles permissions automatically through the RBAC system. Use custom permission functions only when you need authorization logic beyond standard role-based access control.