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>
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 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:
- If any statement explicitly denies →
DecisionDeny - If any statement explicitly allows →
DecisionAllow - 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:
- Load organization membership for the resource's organization
- Load principal attributes (identity + membership role)
- Load resource attributes via
AuthorizationAttributes()on the entity - Build policies: identity-scoped + role-specific
- Evaluate all policies
- Return an authorization scope (
*coredata.Scope) for downstream data access - Return
ErrInsufficientPermissionsif 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_idattributes returnErrMixedOrganizationBatch - Empty resource list returns
ErrEmptyResourceBatch - Batch attributes are required — each resource type in
AuthorizeBatchmust implement batch attributes loading or it returnsErrBatchAuthorizationUnsupportedResourceType - Shared
ResourceAttributesmap is applied to every resource in the batch - Audit logs are written per resource only when all resources are authorized (
DryRunskips 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:
resourceIDsis non-emptyresourceIDsis 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:
- Action constants — add
core:<entity>:<verb>constants inpkg/probo/actions.go(get, list, create, update, delete) - Role policies — wire actions into the appropriate role policies in
pkg/probo/policies.go(OwnerPolicy,AdminPolicy,ViewerPolicy, etc.) withorganization_idcondition AuthorizationAttributes— implement on thecoredataentity struct, returning at minimum{"organization_id": ...}(use the denormalizedOrganizationIDfield — see coredata doc)- Entity type registry — register in
pkg/coredata/entity_type_reg.goandNewEntityFromIDso the authorizer can construct the entity from its GID - Resolver calls — add
scope, err := r.authorize(ctx, id, probo.ActionEntityGet)in GraphQL resolvers andscope, err := r.Authorize(ctx, id, probo.ActionEntityGet)in MCP resolvers, then passscopeto services
Key patterns
- Always use
organization_idcondition — 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