From 5efe9e5b0f05df469cf03a5e8138d0cd9b1d7079 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Sun, 19 Apr 2026 11:49:55 +0200 Subject: [PATCH] Update agent rules Signed-off-by: Bryan Frimin --- contrib/claude/api-surface.md | 43 +++++++++++++++++++++++++++++++++ contrib/claude/authorization.md | 25 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/contrib/claude/api-surface.md b/contrib/claude/api-surface.md index 563a362a5..55f557b7b 100644 --- a/contrib/claude/api-surface.md +++ b/contrib/claude/api-surface.md @@ -10,3 +10,46 @@ Every feature must be exposed through **all four interfaces**: GraphQL, MCP, CLI If you add a mutation in GraphQL, add the corresponding MCP tool, CLI command, and n8n node. If you rename or change a type, update it everywhere. Every new Go API endpoint must have end-to-end tests in `e2e/`. + +## Error handling — never leak internal details + +By default every error returned to the end user **must be an opaque internal error**. Only errors that are explicitly matched and mapped to a known category may surface a meaningful message. Unrecognized or unexpected errors are always replaced with a generic "internal server error" response — never expose stack traces, SQL errors, file paths, or any implementation detail. + +### Allowed user-facing error categories + +| Category | GraphQL helper | HTTP helper | When to use | +|---|---|---|---| +| Not found | `gqlutils.NotFound` / `NotFoundf` | `jsonutil.RenderNotFound` | Resource does not exist or is not visible to the caller | +| Forbidden | `gqlutils.Forbidden` / `Forbiddenf` | `jsonutil.RenderForbidden` | Caller lacks permission (after authentication) | +| Invalid | `gqlutils.Invalid` / `Invalidf` / `InvalidValidationErrors` | `jsonutil.RenderBadRequest` | Validation failure on user-supplied input | +| Conflict | `gqlutils.Conflict` / `Conflictf` | — | Unique constraint or state conflict | +| Unauthenticated | `gqlutils.Unauthenticated` / `Unauthenticatedf` | — | Missing or expired credentials | + +### Catch-all is always internal + +Any error that does **not** match one of the categories above must be returned as: + +- **GraphQL** — `gqlutils.Internal(ctx)` (fixed generic message, no error details) +- **HTTP** — `jsonutil.RenderInternalServerError(w)` (fixed 500 body, no error details) +- **MCP** — return a generic "internal error" string; never forward `err.Error()` + +Log the original error server-side (with request/trace IDs) so it can be investigated, but **never include it in the response**. + +### Pattern in resolvers + +```go +result, err := s.doSomething(ctx, req) +if err != nil { + switch { + case errors.Is(err, probo.ErrNotFound): + return nil, gqlutils.NotFoundf(ctx, "thing %q not found", id) + case errors.Is(err, probo.ErrConflict): + return nil, gqlutils.Conflictf(ctx, "thing already exists") + default: + logger.ErrorCtx(ctx, "cannot do something", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } +} +``` + +The `default` branch must **always** be present and must **always** return the generic internal error. diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md index 0ace643e5..c34e82cec 100644 --- a/contrib/claude/authorization.md +++ b/contrib/claude/authorization.md @@ -2,6 +2,8 @@ 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: @@ -134,6 +136,19 @@ if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil { r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet) ``` +## 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: @@ -158,6 +173,16 @@ const ( | `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::` 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 `r.authorize(ctx, id, probo.ActionEntityGet)` in GraphQL resolvers and `r.MustAuthorize(ctx, id, probo.ActionEntityGet)` in MCP resolvers + ## Key patterns - **Always use `organization_id` condition** — most policies scope access to the principal's organization