Files
probo/contrib/claude/authorization.md
Bryan Frimin 6266cd9a2a Document batch authorization
Describe AuthorizeBatch semantics (all-or-nothing, single-entity-type,
single-organization, batch attribute requirement, audit logging), the
authz.NewBatchAuthorizeFunc / WithBatch* helpers, the MCP
Resolver.AuthorizeBatch entry point, and the new batch-style
AuthorizationAttributes implementation contract (non-empty,
deduplicated, same-entity-type input; only found rows returned).

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-05-23 13:31:37 -07:00

9.6 KiB

Authorization — IAM & Policy

Policy-based authorization in pkg/iam/ using an evaluation model similar to AWS IAM. Explicit deny > explicit allow > implicit deny.

Policies are Go code, not database rows. All policy logic is assembled from Go structs at startup (pkg/probo/policies.go, pkg/iam/iam_policies.go). The database only stores the authz_role enum and membership rows — there is no policies or permissions table. Never create migrations for policy storage.

Core concepts

Policy — a named collection of statements:

policy.NewPolicy("thirdParty-crud", "ThirdParty CRUD",
	policy.Allow(ActionThirdPartyGet, ActionThirdPartyList).WithSID("read-thirdParties"),
	policy.Deny(ActionThirdPartyDelete).WithSID("deny-thirdParty-delete"),
).WithDescription("Standard third party access")

Statement — a single permission rule with effect (allow/deny), actions, optional resources, and optional conditions.

Action format — SERVICE:RESOURCE:OPERATION with wildcard support:

core:thirdParty:create      # specific action
core:thirdParty:*           # all third party actions
core:*                  # all core actions
*                       # everything

Policy evaluation

The evaluator processes all statements against a request:

  1. If any statement explicitly denies → DecisionDeny
  2. If any statement explicitly allows → DecisionAllow
  3. No match → DecisionNoMatch (implicit deny)

Authorizer flow

Authorizer is the main orchestrator in pkg/iam/authorizer.go:

scope, err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{
	Principal:          identityID,    // who
	Resource:           thirdPartyID,      // what
	Action:             probo.ActionThirdPartyGet,  // which action
	ResourceAttributes: map[string]string{},    // optional extra attributes
})

The flow:

  1. Load organization membership for the resource's organization
  2. Load principal attributes (identity + membership role)
  3. Load resource attributes via AuthorizationAttributes() on the entity
  4. Build policies: identity-scoped + role-specific
  5. Evaluate all policies
  6. Return an authorization scope (*coredata.Scope) for downstream data access
  7. Return ErrInsufficientPermissions if no allow match

Batch authorization

Use batch authorization when a caller needs all-or-nothing authorization across multiple resources for the same action:

scope, err := iamService.Authorizer.AuthorizeBatch(ctx, iam.AuthorizeBatchParams{
	Principal: identityID,
	Action:    probo.ActionTaskDelete,
	Resources: taskIDs, // all resources must have same entity type + organization
})

Batch semantics:

  • All-or-nothing — the first denied resource returns ErrInsufficientPermissions
  • Single-entity-type batch — mixed entity types return ErrMixedEntityTypeBatch
  • Single-organization batch — mixed or missing organization_id attributes return ErrMixedOrganizationBatch
  • Empty resource list returns ErrEmptyResourceBatch
  • Batch attributes are required — each resource type in AuthorizeBatch must implement batch attributes loading or it returns ErrBatchAuthorizationUnsupportedResourceType
  • Shared ResourceAttributes map is applied to every resource in the batch
  • Audit logs are written per resource only when all resources are authorized (DryRun skips logs)

GraphQL wrappers can use authz.NewBatchAuthorizeFunc(...) with authz.WithBatchAttr, authz.WithBatchDryRun, and authz.WithBatchSkipAssumptionCheck.

MCP resolvers can use Resolver.AuthorizeBatch(ctx, resourceIDs, action) for the same behavior and error mapping as single-resource authorization.

PolicySet

Policies are organized into identity-scoped (applied to all authenticated users) and role-based:

ps := iam.NewPolicySet().
	AddRolePolicy("OWNER", OwnerPolicy).
	AddRolePolicy("ADMIN", AdminPolicy).
	AddRolePolicy("VIEWER", ViewerPolicy).
	AddIdentityScopedPolicy(SelfManagePolicy)

Register during service initialization:

iamService.Authorizer.RegisterPolicySet(ProboPolicySet())

Conditions (attribute-based access control)

Conditions constrain when a statement applies. All conditions must be satisfied.

// Users can only access resources in their organization
organizationCondition := policy.Equals("principal.organization_id", "resource.organization_id")

policy.Allow(ActionThirdPartyGet).
	WithSID("view-thirdParty").
	When(organizationCondition)
Operator Purpose
policy.Equals(key, value) Key equals value
policy.NotEquals(key, value) Key does not equal value
policy.In(key, value) Key in list (supports comma-separated DB fields)
policy.NotIn(key, value) Key not in list

Key paths use principal.ATTR or resource.ATTR (e.g., principal.organization_id, resource.source).

AuthorizationAttributer interface

Resources that support authorization must implement this interface in pkg/coredata/:

func (v *ThirdParty) AuthorizationAttributes(
	ctx context.Context,
	conn pg.Querier,
	resourceIDs []gid.GID,
) (map[gid.GID]map[string]string, error) {
	q := `SELECT id, organization_id FROM third_parties WHERE id = ANY(@resource_ids::text[])`

	rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"resource_ids": resourceIDs})
	if err != nil {
		return nil, fmt.Errorf("cannot query third party authorization attributes: %w", err)
	}
	defer rows.Close()

	attrsByID := make(map[gid.GID]map[string]string)
	for rows.Next() {
		var id, organizationID gid.GID
		if err := rows.Scan(&id, &organizationID); err != nil {
			return nil, fmt.Errorf("cannot scan third party authorization attributes: %w", err)
		}
		attrsByID[id] = map[string]string{"organization_id": organizationID.String()}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("cannot iterate third party authorization attributes: %w", err)
	}

	return attrsByID, nil
}

The returned map provides attributes for condition evaluation (e.g., resource.organization_id). AuthorizationAttributes implementers should assume caller-side preconditions:

  • resourceIDs is non-empty
  • resourceIDs is deduplicated
  • in AuthorizeBatch, all resources are the same entity type

Implementers should return only found rows keyed by id. Missing resources are handled by caller-side per-resource existence checks.

Error types

var (
	ErrInsufficientPermissions // access denied
	ErrAssumptionRequired      // session assumption needed
	ErrUnsupportedPrincipalType // principal is not an Identity
)

Integration in resolvers

GraphQL resolvers use AuthorizeFunc from pkg/server/api/authz/:

scope, err := authorize(ctx, thirdPartyID, probo.ActionThirdPartyGet)
if err != nil {
	return nil, err
}

MCP resolvers use Authorize and return early on error:

scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyGet)
if err != nil {
	return nil, types.GetThirdPartyOutput{}, err
}

File locations

What File
Product action constants (core:*) pkg/probo/actions.go
IAM action constants (iam:*) pkg/iam/iam_actions.go
Product role policies (ProboPolicySet) pkg/probo/policies.go
IAM role policies (IAMPolicySet) pkg/iam/iam_policies.go
Authorizer + AuthorizationAttributer pkg/iam/authorizer.go
PolicySet registration pkg/iam/policy_set.go
GraphQL authz helper pkg/server/api/authz/authorization.go
MCP authz + recovery pkg/server/api/mcp/v1/resolver.go, mcputils/recovery.go

Action constants

IAM actions live in pkg/iam/iam_actions.go, probo actions in pkg/probo/actions.go. Follow the naming pattern:

const (
	ActionThirdPartyGet    = "core:thirdParty:get"
	ActionThirdPartyList   = "core:thirdParty:list"
	ActionThirdPartyCreate = "core:thirdParty:create"
	ActionThirdPartyUpdate = "core:thirdParty:update"
	ActionThirdPartyDelete = "core:thirdParty:delete"
)

Built-in role policies

Role Access level
OWNER Full access to all features including org management
ADMIN Full access to core features, restricted org management
VIEWER Read-only access to most entities
AUDITOR Read-only, excludes internal/employee content
EMPLOYEE Can sign documents and view internal content

New entity IAM wiring

When adding a new entity that needs authorization:

  1. Action constants — add core:<entity>:<verb> constants in pkg/probo/actions.go (get, list, create, update, delete)
  2. Role policies — wire actions into the appropriate role policies in pkg/probo/policies.go (OwnerPolicy, AdminPolicy, ViewerPolicy, etc.) with organization_id condition
  3. AuthorizationAttributes — implement on the coredata entity struct, returning at minimum {"organization_id": ...} (use the denormalized OrganizationID field — see coredata doc)
  4. Entity type registry — register in pkg/coredata/entity_type_reg.go and NewEntityFromID so the authorizer can construct the entity from its GID
  5. Resolver calls — add scope, err := r.authorize(ctx, id, probo.ActionEntityGet) in GraphQL resolvers and scope, err := r.Authorize(ctx, id, probo.ActionEntityGet) in MCP resolvers, then pass scope to services

Key patterns

  • Always use organization_id condition — most policies scope access to the principal's organization
  • SID every statement — .WithSID("description") for debugging
  • Explicit denies for restrictions — even if allow would match, deny takes precedence
  • Identity-scoped for self-management — cross-org permissions like managing own profile
  • Role-based for org features — CRUD operations on domain entities