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

249 lines
9.6 KiB
Markdown

# 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:
```go
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`:
```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:
```go
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:
```go
ps := iam.NewPolicySet().
AddRolePolicy("OWNER", OwnerPolicy).
AddRolePolicy("ADMIN", AdminPolicy).
AddRolePolicy("VIEWER", ViewerPolicy).
AddIdentityScopedPolicy(SelfManagePolicy)
```
Register during service initialization:
```go
iamService.Authorizer.RegisterPolicySet(ProboPolicySet())
```
## Conditions (attribute-based access control)
Conditions constrain when a statement applies. All conditions must be satisfied.
```go
// 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/`:
```go
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
```go
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/`:
```go
scope, err := authorize(ctx, thirdPartyID, probo.ActionThirdPartyGet)
if err != nil {
return nil, err
}
```
**MCP resolvers** use `Authorize` and return early on error:
```go
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:
```go
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