diff --git a/AGENTS.md b/AGENTS.md index 6433fba3d..5f9c294b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,16 +26,21 @@ GraphQL and MCP codegen is triggered by `go generate`: ## Reference Documentation Detailed guides for specific subsystems live in `contrib/claude/`: -- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination) -- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination) +- [`contrib/claude/authorization.md`](contrib/claude/authorization.md) — IAM policy-based authorization (policies, conditions, roles, AuthorizationAttributer) +- [`contrib/claude/cli.md`](contrib/claude/cli.md) — CLI command patterns (cobra, huh prompts, pagination, output formatting) - [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions -- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types) +- [`contrib/claude/coredata.md`](contrib/claude/coredata.md) — Data access layer (Scoper, SQL patterns, filters, order fields, migrations) +- [`contrib/claude/e2e.md`](contrib/claude/e2e.md) — End-to-end testing (factory builders, RBAC tests, tenant isolation, assertions) +- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation) - [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming) - [`contrib/claude/go-worker.md`](contrib/claude/go-worker.md) — Go worker pattern (poll-based, bounded concurrency, FOR UPDATE SKIP LOCKED) -- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation) -- [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services) +- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination) +- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types) +- [`contrib/claude/mcp.md`](contrib/claude/mcp.md) — MCP API patterns (specification.yaml, mcpgen, resolvers, type helpers) +- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination) - [`contrib/claude/release.md`](contrib/claude/release.md) — Release process (version bump, changelog, tag, push) - [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services) +- [`contrib/claude/validation.md`](contrib/claude/validation.md) — Validation framework (fluent API, validators, error codes, propagation) ## API Surface Rules diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md new file mode 100644 index 000000000..0ace643e5 --- /dev/null +++ b/contrib/claude/authorization.md @@ -0,0 +1,167 @@ +# Authorization — IAM & Policy + +Policy-based authorization in `pkg/iam/` using an evaluation model similar to AWS IAM. Explicit deny > explicit allow > implicit deny. + +## Core concepts + +**Policy** — a named collection of statements: +```go +policy.NewPolicy("vendor-crud", "Vendor CRUD", + policy.Allow(ActionVendorGet, ActionVendorList).WithSID("read-vendors"), + policy.Deny(ActionVendorDelete).WithSID("deny-vendor-delete"), +).WithDescription("Standard vendor 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:vendor:create # specific action +core:vendor:* # all vendor 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 +err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{ + Principal: identityID, // who + Resource: vendorID, // what + Action: probo.ActionVendorGet, // 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 `ErrInsufficientPermissions` if no allow match + +## 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(ActionVendorGet). + WithSID("view-vendor"). + 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 *Vendor) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) { + q := `SELECT organization_id FROM vendors WHERE id = $1 LIMIT 1;` + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query vendor authorization attributes: %w", err) + } + return map[string]string{"organization_id": organizationID.String()}, nil +} +``` + +The returned map provides attributes for condition evaluation (e.g., `resource.organization_id`). + +## 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 +if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil { + return nil, err +} +``` + +**MCP resolvers** use `MustAuthorize` which panics (caught by middleware): +```go +r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet) +``` + +## Action constants + +IAM actions live in `pkg/iam/iam_actions.go`, probo actions in `pkg/probo/actions.go`. Follow the naming pattern: + +```go +const ( + ActionVendorGet = "core:vendor:get" + ActionVendorList = "core:vendor:list" + ActionVendorCreate = "core:vendor:create" + ActionVendorUpdate = "core:vendor:update" + ActionVendorDelete = "core:vendor: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 | + +## 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 diff --git a/contrib/claude/cli.md b/contrib/claude/cli.md new file mode 100644 index 000000000..cb0bbee79 --- /dev/null +++ b/contrib/claude/cli.md @@ -0,0 +1,231 @@ +# CLI Command Patterns + +CLI commands use [cobra](https://github.com/spf13/cobra) with `pkg/cmd/cmdutil.Factory` for shared dependencies. Each resource gets a group command with verb subcommands (`list`, `create`, `view`, `update`, `delete`). + +## Directory structure + +``` +pkg/cmd//.go # Group command, wires verbs +pkg/cmd//list/list.go # List verb +pkg/cmd//create/create.go # Create verb +pkg/cmd//view/view.go # View verb +pkg/cmd//update/update.go # Update verb +pkg/cmd//delete/delete.go # Delete verb +pkg/cmd/cmdutil/ # Factory, flags, output helpers +pkg/cmd/iostreams/ # Terminal I/O abstraction +pkg/cli/api/ # GraphQL client, pagination +``` + +Register group commands in `pkg/cmd/root/root.go` with `cmd.AddCommand()`. + +## Leaf command pattern + +Every leaf command follows this structure: + +```go +package list + +const listQuery = `query($id: ID!, $first: Int, $after: CursorKey) { ... }` + +type listResponse struct { ... } // unexported, shaped to match GraphQL response + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List resources", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // 1. Validate output flag + // 2. Load config, get host + token + // 3. Create api.Client + // 4. Resolve --org (flag or config default) + // 5. Call API + // 6. Output results + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of items") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} +``` + +## Pagination with `api.Paginate[T]` + +```go +resources, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[resource], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Resources api.Connection[resource] `json:"resources"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("organization %s not found", flagOrg) + } + return &resp.Node.Resources, nil + }, +) +``` + +## Output formatting + +**JSON output:** +```go +if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resources) +} +``` + +**Table output:** +```go +rows := make([][]string, 0, len(resources)) +for _, r := range resources { + rows = append(rows, []string{r.ID, r.Name}) +} +t := cmdutil.NewTable("ID", "NAME").Rows(rows...) +_, _ = fmt.Fprintln(f.IOStreams.Out, t) +``` + +**View detail output with lipgloss:** +```go +bold := lipgloss.NewStyle().Bold(true) +label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22) + +_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(r.Name)) +_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), r.ID) +_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(r.CreatedAt)) +``` + +**Truncation message** (stderr, not stdout): +```go +if totalCount > len(resources) { + _, _ = fmt.Fprintf(f.IOStreams.ErrOut, "\nShowing %d of %d resources\n", len(resources), totalCount) +} +``` + +## Interactive prompts with `charmbracelet/huh` + +Gate all prompts behind interactivity check, then validate: + +```go +if f.IOStreams.IsInteractive() { + if flagName == "" { + err := huh.NewInput().Title("Resource name").Value(&flagName).Run() + if err != nil { + return err + } + } + + if flagCategory == "" { + err := huh.NewSelect[string](). + Title("Category"). + Options( + huh.NewOption("Cloud Provider", "CLOUD_PROVIDER"), + huh.NewOption("SaaS", "SAAS"), + ). + Value(&flagCategory).Run() + if err != nil { + return err + } + } +} + +if flagName == "" { + return fmt.Errorf("name is required; pass --name or run interactively") +} +``` + +Available prompt types: `huh.NewInput()` (text), `huh.NewText()` (multiline), `huh.NewSelect[T]()` (dropdown), `huh.NewConfirm()` (yes/no). + +## Update commands + +Only include fields that were explicitly changed: + +```go +input := map[string]any{"id": args[0]} + +if cmd.Flags().Changed("name") { + input["name"] = flagName +} +if cmd.Flags().Changed("description") { + input["description"] = flagDescription +} + +if len(input) == 1 { + return fmt.Errorf("at least one field must be specified for update") +} +``` + +## Delete commands + +Require confirmation via `--yes` flag or interactive prompt: + +```go +if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete resource: confirmation required, use --yes to confirm") + } + var confirmed bool + err := huh.NewConfirm().Title(fmt.Sprintf("Delete %s?", args[0])).Value(&confirmed).Run() + if err != nil { + return err + } + if !confirmed { + return nil + } +} +``` + +## Organization resolution + +```go +if flagOrg == "" { + flagOrg = hc.Organization +} +if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") +} +``` + +## Flag conventions + +- Use kebab-case: `--order-by`, `--inherent-likelihood` +- Short flags for common options: `-L` (limit), `-o` (output), `-q` (query), `-y` (yes) +- `StringVar`/`IntVar`/`BoolVar` for flags, positional args only for IDs in view/update/delete +- Use `cmd.MarkFlagRequired()` for mandatory flags + +## IOStreams + +```go +f.IOStreams.Out // stdout — primary output +f.IOStreams.ErrOut // stderr — status messages, truncation info +f.IOStreams.IsInteractive() // true if TTY and not forced non-interactive +``` + +Environment variables: `PROBO_NO_INTERACTIVE=1`, `CI=true`, `TERM=dumb` (non-interactive), `NO_COLOR` (disable color). + +## New resource command checklist + +1. **Group command** — `pkg/cmd//.go` with `NewCmd(f)`, wiring all verb subcommands +2. **Leaf commands** — one file per verb in `pkg/cmd///.go` +3. **Each leaf file** — ISC license header, GraphQL const, unexported response struct, `NewCmd(f)` function +4. **Register in root** — import and `cmd.AddCommand()` in `pkg/cmd/root/root.go` +5. **API surface** — update GraphQL schema, MCP tools, and e2e tests diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md new file mode 100644 index 000000000..15cd1e638 --- /dev/null +++ b/contrib/claude/coredata.md @@ -0,0 +1,169 @@ +# Coredata — Data Access Layer + +All raw SQL lives in `pkg/coredata` — never in service, handler, or resolver packages. One file per entity, with companion `*_filter.go` and `*_order_field.go` files when needed. + +## Entity struct pattern + +Every entity uses `gid.GID` for its ID, `db` tags for pgx mapping, and `CreatedAt`/`UpdatedAt` timestamps. The `tenant_id` column exists in the database but is **never** stored on the Go struct — it is injected at query time via `Scoper`. + +```go +type ( + Asset struct { + ID gid.GID `db:"id"` + SnapshotID *gid.GID `db:"snapshot_id"` + Name string `db:"name"` + OrganizationID gid.GID `db:"organization_id"` + AssetType AssetType `db:"asset_type"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + Assets []*Asset +) +``` + +Use pointer types (`*T`) for nullable database columns. + +## Scoper interface + +`Scoper` provides tenant isolation. Two implementations: + +| Type | Constructor | `SQLFragment()` | `GetTenantID()` | Use case | +|------|-------------|-----------------|-----------------|----------| +| `Scope` | `NewScope(tenantID)` or `NewScopeFromObjectID(gid)` | `"tenant_id = @tenant_id"` | Returns tenant ID | Multi-tenant queries (default) | +| `NoScope` | `NewNoScope()` | `"TRUE"` | **Panics** — never call | Cross-tenant / administrative queries | + +Always inject `tenant_id` at INSERT time using `scope.GetTenantID()`, never from the struct. + +## SQL query composition + +All queries use `fmt.Sprintf` to inject scope/filter/cursor fragments, then `pgx.StrictNamedArgs` for parameters. Merge args with `maps.Copy`. + +```go +q := ` +SELECT id, name, created_at, updated_at +FROM assets +WHERE + %s + AND organization_id = @organization_id + AND %s + AND %s +LIMIT %d; +` + +q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment(), cursor.Limit()) + +args := pgx.StrictNamedArgs{"organization_id": organizationID} +maps.Copy(args, scope.SQLArguments()) +maps.Copy(args, filter.SQLArguments()) +maps.Copy(args, cursor.SQLArguments()) +``` + +**All SQL must be static** after `fmt.Sprintf()` injection — no conditional string building. Use `CASE WHEN` in SQL for optional filter logic. + +## Standard method signatures + +| Method | Receiver | Returns | Purpose | +|--------|----------|---------|---------| +| `LoadByID(ctx, conn, scope, id)` | `*Entity` | `error` | Single entity by ID | +| `LoadBy*(ctx, conn, scope, key)` | `*Entity` | `error` | Single entity by unique key | +| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list | +| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows | +| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` | +| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` | +| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity | +| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination | +| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation | + +## Row collection + +```go +// Single row +rows, err := conn.Query(ctx, q, args) +asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset]) +if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound +} +*a = asset + +// Multiple rows +rows, err := conn.Query(ctx, q, args) +assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset]) +*a = assets +``` + +## Sentinel errors + +```go +var ( + ErrResourceNotFound = errors.New("resource not found") + ErrResourceAlreadyExists = errors.New("resource already exists") + ErrResourceInUse = errors.New("resource is in use") +) +``` + +Map `pgx.ErrNoRows` to `ErrResourceNotFound`. Check unique constraint violations for `ErrResourceAlreadyExists`, foreign key violations for `ErrResourceInUse`. + +## Filters + +Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals. + +```go +type AssetFilter struct { + snapshotID **gid.GID +} + +func NewAssetFilter(snapshotID **gid.GID) *AssetFilter { + return &AssetFilter{snapshotID: snapshotID} +} + +func (f *AssetFilter) SQLFragment() string { + if f.snapshotID == nil { + return "TRUE" + } + if *f.snapshotID == nil { + return "snapshot_id IS NULL" + } + return "snapshot_id = @filter_snapshot_id" +} + +func (f *AssetFilter) SQLArguments() pgx.NamedArgs { + if f.snapshotID == nil || *f.snapshotID == nil { + return pgx.NamedArgs{} + } + return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID} +} +``` + +For complex multi-field filters, use `CASE WHEN` in SQL and always declare all argument keys in every code path (use `nil` for inactive ones). + +## Order fields + +String-based enums with `Column()`, `IsValid()`, `String()`, and `MarshalText`/`UnmarshalText`: + +```go +type AssetOrderField string + +const ( + AssetOrderFieldCreatedAt AssetOrderField = "CREATED_AT" + AssetOrderFieldName AssetOrderField = "NAME" +) +``` + +Each entity implements `CursorKey(field)` returning `page.NewCursorKey(entity.ID, sortValue)`, with a `panic` on unknown fields. + +## Entity type registry + +Each entity gets a unique `uint16` constant in `entity_type_reg.go`. **Never reuse** removed type numbers — use `_` placeholder. Register new entities in the `NewEntityFromID` switch statement. + +## Migrations + +Files in `pkg/coredata/migrations/` use timestamp naming: `YYYYMMDDTHHMMSSZ.sql` (UTC). One logical change per file. Always create indexes for frequently queried columns. + +## New entity checklist + +1. **Entity file** (`entity.go`) — struct with `db` tags, slice type alias, `LoadByID`, `Insert`, `Update`, `Delete`, `CursorKey`, `AuthorizationAttributes` +2. **Filter file** (`entity_filter.go`) — filter struct, `NewEntityFilter`, `SQLFragment`, `SQLArguments` +3. **Order field file** (`entity_order_field.go`) — order field type, constants, `Column`, `IsValid`, marshaling +4. **Entity type constant** — add to `entity_type_reg.go` and `NewEntityFromID` +5. **Migration** — `YYYYMMDDTHHMMSSZ.sql` with CREATE TABLE diff --git a/contrib/claude/e2e.md b/contrib/claude/e2e.md new file mode 100644 index 000000000..e9ce93167 --- /dev/null +++ b/contrib/claude/e2e.md @@ -0,0 +1,237 @@ +# End-to-End Testing + +E2E tests live in `e2e/console/` (package `console_test`) and run against a live `bin/probod` instance. The test infrastructure handles server lifecycle, authentication, and test data creation. + +## Running tests + +```bash +SKIP_APPS=1 make build # Build the binary (backend only) +make test-e2e # Run all e2e tests +``` + +## Client setup + +**Standalone user (new organization):** +```go +owner := testutil.NewClient(t, testutil.RoleOwner) +``` + +**User in existing organization:** +```go +admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) +viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) +``` + +Each call creates a unique identity with a fresh email. Available roles: `RoleOwner`, `RoleAdmin`, `RoleViewer`. + +## Client methods + +| Method | API | Purpose | +|--------|-----|---------| +| `c.Execute(query, vars, &result)` | Console | Execute and unmarshal into result | +| `c.MustExecute(query, vars, &result)` | Console | Execute, fail test on error | +| `c.ExecuteShouldFail(query, vars)` | Console | Expect error, fail if succeeds | +| `c.Do(query, vars)` | Console | Low-level, returns raw response | +| `c.ExecuteConnect(query, vars, &result)` | Connect | For auth operations | +| `c.ExecuteWithFile(query, vars, path, file, &result)` | Console | Single file upload | +| `c.GetOrganizationID()` | — | Current org GID | +| `c.GetUserID()` | — | Current user GID | + +## Test data factories + +Two patterns in `e2e/internal/factory/`: + +**Builder pattern (preferred):** +```go +vendorID := factory.NewVendor(owner). + WithName("Stripe"). + WithCategory("CLOUD_PROVIDER"). + Create() + +frameworkID := factory.NewFramework(owner). + WithName("SOC 2"). + Create() + +controlID := factory.NewControl(owner, frameworkID). + WithName("Access Control"). + Create() +``` + +**Simple factory functions:** +```go +vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"}) +taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"}) +``` + +Use `factory.SafeName("prefix")` for unique names and `factory.SafeEmail()` for unique emails. + +## Test structure + +Every test and subtest **must** call `t.Parallel()`. One test file per entity in `e2e/console/`. Function naming: `TestEntity_Operation`. + +```go +func TestVendor_Create(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run("with required fields", func(t *testing.T) { + t.Parallel() + + const query = ` + mutation CreateVendor($input: CreateVendorInput!) { + createVendor(input: $input) { + vendorEdge { + node { id name } + } + } + } + ` + + var result struct { + CreateVendor struct { + VendorEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"vendorEdge"` + } `json:"createVendor"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": factory.SafeName("Vendor"), + }, + }, &result) + + require.NoError(t, err) + assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID) + }) +} +``` + +## Authorization (RBAC) testing + +Test each role's access to each operation: + +```go +t.Run("viewer cannot create", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + _, err := viewer.Do(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": viewer.GetOrganizationID().String(), + "name": "Test", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer cannot create") +}) +``` + +## Tenant isolation testing + +```go +t.Run("other org cannot access", func(t *testing.T) { + t.Parallel() + owner1 := testutil.NewClient(t, testutil.RoleOwner) + owner2 := testutil.NewClient(t, testutil.RoleOwner) + + vendorID := factory.NewVendor(owner1).WithName("Vendor").Create() + + var result struct { + Node *struct{ ID string } `json:"node"` + } + err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor") +}) +``` + +## Assertion helpers + +**Pagination:** +```go +testutil.AssertFirstPage(t, edgeCount, pageInfo, expectedCount, expectMore) +testutil.AssertMiddlePage(t, edgeCount, pageInfo, expectedCount) +testutil.AssertLastPage(t, edgeCount, pageInfo, expectedCount, expectPrevious) +``` + +**Timestamps:** +```go +testutil.AssertTimestampsOnCreate(t, createdAt, updatedAt, beforeCreate) +testutil.AssertTimestampsOnUpdate(t, createdAt, updatedAt, origCreatedAt, origUpdatedAt) +``` + +**Ordering:** +```go +testutil.AssertOrderedAscending[T](t, values, "fieldName") +testutil.AssertOrderedDescending[T](t, values, "fieldName") +testutil.AssertTimesOrderedDescending(t, times, "createdAt") +``` + +**Authorization:** +```go +testutil.RequireForbiddenError(t, err, "message") +testutil.RequireErrorCode(t, err, "CODE_NAME", "message") +``` + +**Optional fields:** +```go +testutil.AssertOptionalStringEqual(t, expected, actual, "fieldName") +``` + +## Validation testing + +Use table-driven tests for validation scenarios: + +```go +tests := []struct { + name string + input map[string]any + wantErrorContains string +}{ + {name: "missing name", input: map[string]any{}, wantErrorContains: "name"}, + {name: "HTML injection", input: map[string]any{"name": ""}, wantErrorContains: "HTML"}, + {name: "control char", input: map[string]any{"name": "Test\x00"}, wantErrorContains: "control"}, +} + +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := map[string]any{"organizationId": owner.GetOrganizationID().String()} + maps.Copy(input, tt.input) + + _, err := owner.Do(query, map[string]any{"input": input}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrorContains) + }) +} +``` + +## File uploads + +```go +err := owner.ExecuteWithFile( + uploadQuery, + map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}}, + "input.file", + testutil.UploadFile{ + Filename: "report.pdf", + ContentType: "application/pdf", + Content: pdfBytes, + }, + &result, +) +``` + +## New entity e2e test checklist + +1. **File** — `e2e/console/_test.go`, package `console_test` +2. **CRUD** — create (required fields, all fields), update, delete, get by ID, list +3. **Validation** — required fields, empty strings, HTML injection, control chars, max length, invalid enums +4. **RBAC** — owner/admin/viewer access for create, update, delete, read +5. **Tenant isolation** — cross-org user cannot access resource +6. **Timestamps** — `createdAt == updatedAt` on create, `updatedAt` advances on update +7. **Sub-resolvers** — parent references, child collections +8. **Parallel** — `t.Parallel()` on every test and subtest diff --git a/contrib/claude/mcp.md b/contrib/claude/mcp.md new file mode 100644 index 000000000..7fe4efbb6 --- /dev/null +++ b/contrib/claude/mcp.md @@ -0,0 +1,194 @@ +# MCP API Patterns + +MCP tools are defined in `pkg/server/api/mcp/v1/specification.yaml` and generated with `mcpgen`. The schema is hand-written; Go types, server registration, and resolver stubs are generated. + +## File organization + +**Hand-written** (edit these): +- `specification.yaml` — tool definitions, input/output schemas, component schemas +- `resolver.go` — `Resolver` struct, `MustAuthorize`, service accessors +- `helpers.go` — pagination helpers, `UnwrapOmittable` +- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewVendor()`, `NewListVendorsOutput()`, etc.) +- `schema.resolvers.go` — tool implementation bodies (stubs generated, you edit the bodies) + +**Generated** (do not edit): +- `server/server.go` — tool registration, `ResolverInterface` +- `types/types.go` — type definitions and JSON schemas + +After modifying `specification.yaml`, run: +```bash +go generate ./pkg/server/api/mcp/v1 +``` + +## Tool definition in specification.yaml + +```yaml +tools: + - name: listVendors + description: List all vendors for the organization + hints: + readonly: true + idempotent: true + destructive: false + inputSchema: + $ref: "#/components/schemas/ListVendorsInput" + outputSchema: + $ref: "#/components/schemas/ListVendorsOutput" +``` + +Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension: + +```yaml +components: + schemas: + GID: + type: string + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/gid.GID +``` + +## Resolver signature + +Generated stubs follow this pattern: + +```go +func (r *Resolver) ListVendorsTool( + ctx context.Context, + req *mcp.CallToolRequest, + input *types.ListVendorsInput, +) (*mcp.CallToolResult, types.ListVendorsOutput, error) +``` + +First return is always `nil`. Errors are either returned (for recoverable) or panicked (for authorization and unexpected failures). + +## Authorization + +Use `MustAuthorize` which panics on failure (caught by middleware): + +```go +r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList) +``` + +## Common resolver patterns + +**List with pagination:** +```go +func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList) + + prb := r.ProboService(ctx, input.OrganizationID) + + pageOrderBy := page.OrderBy[coredata.VendorOrderField]{ + Field: coredata.VendorOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.VendorOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewVendorFilter(nil, nil)) + if err != nil { + panic(fmt.Errorf("cannot list vendors: %w", err)) + } + + return nil, types.NewListVendorsOutput(page), nil +} +``` + +**Get single resource:** +```go +func (r *Resolver) GetRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetRiskInput) (*mcp.CallToolResult, types.GetRiskOutput, error) { + r.MustAuthorize(ctx, input.ID, probo.ActionRiskGet) + prb := r.ProboService(ctx, input.ID) + + risk, err := prb.Risks.Get(ctx, input.ID) + if err != nil { + return nil, types.GetRiskOutput{}, fmt.Errorf("failed to get risk: %w", err) + } + + return nil, types.GetRiskOutput{Risk: types.NewRisk(risk)}, nil +} +``` + +**Create:** +```go +func (r *Resolver) AddRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddRiskInput) (*mcp.CallToolResult, types.AddRiskOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionRiskCreate) + svc := r.ProboService(ctx, input.OrganizationID) + + risk, err := svc.Risks.Create(ctx, probo.CreateRiskRequest{ + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: input.Description, + }) + if err != nil { + return nil, types.AddRiskOutput{}, fmt.Errorf("failed to create risk: %w", err) + } + + return nil, types.AddRiskOutput{Risk: types.NewRisk(risk)}, nil +} +``` + +## Optional fields with Omittable + +For nullable update fields, use `go.probo.inc/mcpgen/omittable: true` in the schema: + +```yaml +description: + type: + - string + - "null" + go.probo.inc/mcpgen/omittable: true +``` + +In resolvers, unwrap with `UnwrapOmittable`: + +```go +Description: UnwrapOmittable(input.Description), +``` + +## Type conversion helpers + +Live in `types/*.go` (not the generated `types/types.go`). One file per entity: + +```go +func NewVendor(v *coredata.Vendor) *Vendor { + return &Vendor{ + ID: v.ID, + OrganizationID: v.OrganizationID, + Name: v.Name, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + } +} + +func NewListVendorsOutput(vendorPage *page.Page[*coredata.Vendor, coredata.VendorOrderField]) ListVendorsOutput { + vendors := make([]*Vendor, 0, len(vendorPage.Data)) + for _, v := range vendorPage.Data { + vendors = append(vendors, NewVendor(v)) + } + + var nextCursor *page.CursorKey + if len(vendorPage.Data) > 0 { + cursorKey := vendorPage.Data[len(vendorPage.Data)-1].CursorKey(vendorPage.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListVendorsOutput{ + NextCursor: nextCursor, + Vendors: vendors, + } +} +``` + +## Adding a new MCP tool — checklist + +1. **Schema** — add input/output schemas and tool definition in `specification.yaml` +2. **Codegen** — `go generate ./pkg/server/api/mcp/v1` +3. **Resolver** — implement the tool body in `schema.resolvers.go` (authorize, call service, convert types) +4. **Type helpers** — add `New()` and `New()` in `types/.go` +5. **Verify** — tool is automatically registered via generated `server/server.go` diff --git a/contrib/claude/validation.md b/contrib/claude/validation.md new file mode 100644 index 000000000..c6d2b7aed --- /dev/null +++ b/contrib/claude/validation.md @@ -0,0 +1,144 @@ +# Validation Framework + +Custom fluent validation API in `pkg/validator/`. Used in every service method to validate request structs before processing. + +## Basic pattern + +Create a validator, chain `Check()` calls for each field, then call `Error()` to get accumulated errors: + +```go +func (req *CreateVendorRequest) Validate() error { + v := validator.New() + + v.Check(req.OrganizationID, "organization_id", + validator.Required(), + validator.GID(coredata.OrganizationEntityType), + ) + v.Check(req.Name, "name", + validator.Required(), + validator.SafeTextNoNewLine(TitleMaxLength), + ) + v.Check(req.Category, "category", + validator.OneOfSlice(coredata.VendorCategories()), + ) + + return v.Error() +} +``` + +`Check(value, fieldName, validators...)` runs validators sequentially on a value. Multiple `Check()` calls accumulate all errors. `Error()` returns `nil` if clean, or `ValidationErrors` (which implements `error`). + +## Available validators + +### Common +- `Required()` — value must not be nil, empty string, or empty slice +- `NotEmpty()` — value cannot be empty/nil (only checks content, not presence) + +### String +- `MinLen(n)` — at least n characters +- `MaxLen(n)` — at most n characters +- `OneOfSlice[T](allowed)` — value must be in allowed list + +### Numeric +- `Min(n)` — value >= n +- `Max(n)` — value <= n + +### Format +- `URL()` — valid HTTP/HTTPS URL with host +- `HTTPSUrl()` — HTTPS-only URL +- `Domain()` — valid RFC-compliant domain name +- `GID(entityTypes...)` — valid GID, optionally restricted to specific entity types + +### Security +- `NoHTML()` — rejects HTML tags +- `PrintableText()` — rejects invisible/harmful Unicode (control chars, bidi overrides, zero-width) +- `NoNewLine()` — rejects `\n` and `\r` +- `SafeText(maxLen)` — combines NotEmpty + MaxLen + NoHTML + PrintableText (allows newlines) +- `SafeTextNoNewLine(maxLen)` — same as SafeText but also rejects newlines (for single-line fields) + +### Time +- `After(refTime)` — time must be after reference +- `Before(refTime)` — time must be before reference +- `RangeDuration(min, max)` — duration between min and max inclusive + +## Pointer handling + +The framework automatically dereferences pointers at any level. Nil pointers pass all non-`Required` validators: + +```go +v.Check(stringValue, "field", validator.Required()) // Direct value +v.Check(&stringValue, "field", validator.Required()) // Pointer — auto-dereferenced +v.Check(nilPointer, "field", validator.MinLen(5)) // Nil passes (not Required) +v.Check(nilPointer, "field", validator.Required()) // Nil fails Required +``` + +## Collection validation + +Use `CheckEach` to validate each item in a slice: + +```go +v.CheckEach(ids, "ids", func(index int, item any) { + gidValue := item.(gid.GID) + v.Check(gidValue, fmt.Sprintf("ids[%d]", index), + validator.Required(), + validator.GID(coredata.VendorEntityType), + ) +}) +``` + +Nil or empty slices are silently skipped. + +## Error types + +```go +type ValidationError struct { + Field string // e.g. "email" + Code ErrorCode // e.g. ErrorCodeRequired + Message string // human-readable + Value any // the problematic value +} +``` + +Error codes: + +| Code | Meaning | +|------|---------| +| `REQUIRED` | Field is missing or empty | +| `INVALID_FORMAT` | Value does not match expected format | +| `OUT_OF_RANGE` | Numeric value outside bounds | +| `TOO_SHORT` | String below minimum length | +| `TOO_LONG` | String above maximum length | +| `INVALID_EMAIL` | Invalid email address | +| `INVALID_URL` | Invalid URL | +| `INVALID_ENUM` | Value not in allowed set | +| `INVALID_GID` | Invalid GID or wrong entity type | +| `UNSAFE_CONTENT` | HTML, control chars, or harmful Unicode | +| `CUSTOM` | Custom validation error | + +`ValidationErrors` is a slice with query methods: + +```go +errs := err.(validator.ValidationErrors) +errs.HasErrors() // bool +errs.Fields() // unique field names +errs.ByField("name") // filter by field +errs.ByCode(ErrorCodeRequired) // filter by code +errs.First() // first error +``` + +## Error propagation + +Validation errors flow naturally through Go's error interface: + +1. Request struct's `Validate()` returns `ValidationErrors` or `nil` +2. Service method checks error before processing +3. GraphQL/HTTP handlers convert `ValidationErrors` to appropriate response format + +```go +func (s *Service) CreateVendor(ctx context.Context, req CreateVendorRequest) (*coredata.Vendor, error) { + if err := req.Validate(); err != nil { + return nil, err + } + // proceed with business logic +} +``` diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 4336c9582..573cf4cca 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -86,7 +86,7 @@ func (r *applicabilityStatementConnectionResolver) TotalCount(ctx context.Contex return count, nil } - r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T") + r.logger.ErrorCtx(ctx, "unsupported resolver for applicability statement connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver))) return 0, gqlutils.Internal(ctx) } @@ -1771,7 +1771,7 @@ func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context return count, nil } - r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T") + r.logger.ErrorCtx(ctx, "unsupported resolver for mailing list subscriber connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver))) return 0, gqlutils.Internal(ctx) } @@ -7278,7 +7278,7 @@ func (r *profileConnectionResolver) TotalCount(ctx context.Context, obj *types.P return count, nil } - r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T") + r.logger.ErrorCtx(ctx, "unsupported resolver for profile connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver))) return 0, gqlutils.Internal(ctx) }