Files
probo/contrib/claude/authorization.md
Bryan Frimin 4b64e59da4 Introduce access-review source snapshot and normalize naming
Decouple each campaign from the live access-review sources it was started
with by introducing a per-campaign source snapshot table
(access_review_campaign_sources). The snapshot captures the source name,
category, and connector at start time, so a review remains coherent even
after the underlying source is edited or deleted. Fetch tracking becomes
an append-only log (access_review_campaign_source_fetch_attempts) that
preserves every attempt with its own status and error rather than
overwriting a single row.

Rename the shared access-review tables and enums to use a consistent
access_review_ prefix throughout:

  access_entries          → access_review_entries
  access_sources          → access_review_sources
  access_source_category  → access_review_source_category
  access_entry_*          → access_review_entry_*

The same rename propagates to every coredata type, service, GraphQL
schema, MCP specification, CLI command, frontend component, and e2e test.
The accessreview package gains dedicated actions.go and policies.go files
for its own IAM policy set, mirroring the agentrun package pattern.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-06-15 15:33:22 +02:00

12 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 formatSERVICE: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 := r.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
}

Always take scope from authorize — never reconstruct it

authorize (and Authorize in MCP) returns a *coredata.Scope that has been resolved from the resource's organization_id attribute. Pass that scope straight to the service/coredata layer instead of building a new one with coredata.NewScopeFromObjectID(...) after the authorize call.

The two are not strictly identical: NewScopeFromObjectID(id) only reads the tenant component of the GID, while the authorizer derives the scope from the loaded resource attributes (and may be extended to compute it differently in the future). Reconstructing the scope from the GID bypasses that and silently drifts when the resource lookup changes.

// GOOD — scope comes from authorize, fed straight to the service
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
if err != nil {
	return nil, err
}

thirdPartyIDs, err := r.cookieBanner.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, scope, obj.ID)

// BAD — authorize discards scope, then we rebuild it from the same GID
if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList); err != nil {
	return nil, err
}

scope := coredata.NewScopeFromObjectID(obj.ID)
thirdPartyIDs, err := r.cookieBanner.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, scope, obj.ID)

The only time it is acceptable to write if _, err := r.authorize(...) is when no downstream call needs a scope — typically authorize calls against the caller's identity.ID for global / cross-tenant catalogs (e.g. ActionCommonThirdPartyList, ActionCommonThirdPartyGet) whose service methods are unscoped. In that case the returned scope would be derived from the identity (a nil-tenant principal) and is useless to the caller, so discarding it with _ is correct:

// GOOD — global catalog, downstream is unscoped
identity := authn.IdentityFromContext(ctx)
if _, err := r.authorize(ctx, identity.ID, probo.ActionCommonThirdPartyList); err != nil {
	return nil, err
}

parties, err := r.thirdParty.Search(ctx, name) // no scope argument

For batch authorization, the same rule applies to r.batchAuthorize (GraphQL) and r.AuthorizeBatch (MCP) — keep the returned scope and pass it down.

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
Per-service policy sets (e.g. accessreview.PolicySet, agentrun.PolicySet) pkg/<service>/actions.go, pkg/<service>/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