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>
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:
- 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 := 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:
- 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