contract

package
v1.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package contract provides shared test contracts that every Warden store backend must satisfy. Backends import this package from their _test.go files and call the exported `Run*` functions with a factory that builds a fresh store. The contract is the source of truth for "what a Warden store has to do, regardless of backend."

policy_rename.go: renaming a policy onto a name another policy holds in the same tenant and namespace is a duplicate, whichever update call carries the rename.

Create refuses a taken (tenant, namespace, name) with ErrDuplicatePolicy. Update and UpdatePolicyIfVersion must refuse it the same way and write nothing, on every backend. A policy keeping its own name, or taking a name another namespace holds, is not a duplicate.

policy_roundtrip.go: a fully populated policy survives every write and read path.

Policy.Subjects, Actions, Resources, Conditions and Obligations are all db:"-", and each backend persists them its own way: postgres jsonb, sqlite JSON strings, mongo native arrays, memory deep copies. Every other case in this package builds policies with those five fields empty, so until this case the round-trip the dashboard's policy editor writes and reads back was proven on no backend.

Numbers are chosen to be exact in float64, because a JSON round-trip returns them as float64. Integers beyond 2^53 are refused on write by the dashboard contract rather than tested here.

policy_version.go: UpdatePolicyIfVersion writes only over the version the caller read, as one atomic step.

Two editors who opened the same policy at version N both save with expected N. Exactly one of them may win; the other must get ErrPolicyVersionConflict and leave the stored policy exactly as the winner wrote it. A missing id or a foreign tenant is ErrPolicyNotFound, never a conflict, so a caller can tell "someone else changed it" from "it is not there".

relation_delete_tuple.go: a delete by composite key removes exactly the tuple the key names, and nothing else.

The key is tenant, namespace, object type and ID, relation, subject type and ID, and subject relation. An empty subject relation names the direct tuple, so group:eng and group:eng#member on the same object are two keys: deleting one leaves the other. The REST delete reads the match first and audits it, so a store that also removed the other tuple would remove a grant the audit trail never names.

relation_get.go: a relation tuple can be read back by its ID within its own tenant, and only there.

The dashboard deletes a tuple by ID. Its audit event has to carry the tuple that went away, not just the ID, so the handler reads the row first. That read is also the tenant check: another tenant's ID must come back as ErrRelationNotFound, exactly as a missing one does, so the caller learns nothing about rows outside its tenant.

resourcetype_empty_lists.go: a resource type with no relations and no permissions is stored and read back on every backend.

ResourceType.Relations and Permissions are db:"-", and each backend persists them its own way. The REST create handler builds both lists by appending onto nil, so a create that sends neither reaches the store with nil slices, not empty ones. Postgres turned a nil slice into SQL NULL, which its NOT NULL jsonb columns refused.

tenant_maintenance.go: the tenant-scoped maintenance purges remove one tenant's rows and nothing else.

The dashboard's maintenance.run is a tenant's command. Before these methods existed it called the engine-wide purges, so any tenant's operator could delete every tenant's expired assignments and old check log entries. DeleteExpiredAssignmentsForTenant and PurgeCheckLogsForTenant carry the tenant into the delete itself. An empty tenant ID is refused: on these stores an empty tenant filter matches every row (see RunEmptyTenantContract), so passing one through would quietly turn a tenant purge back into the engine-wide one.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RunActorFieldsContract added in v1.7.0

func RunActorFieldsContract(t *testing.T, newStore NewStore)

RunActorFieldsContract asserts that every backend persists CreatedBy and UpdatedBy (GrantedBy for assignments) across a real round trip: create with an actor, update with a different actor where the entity supports updates, read back, and check both fields.

The bug this guards: the postgres and sqlite update column lists were missing updated_by, so an update silently dropped it, and the mongo models never mapped the actor columns at all, so they round-tripped as empty strings on every backend. created_by must never move: an update carries a different actor for created_by too (to catch a store that blindly writes whatever the caller passed), and the read-back must still show the original creator.

func RunAutoIDContract

func RunAutoIDContract(t *testing.T, mk MakeStore)

RunAutoIDContract runs every `Create*` method against a Store constructed by mk and asserts the auto-ID + auto-timestamp behavior:

  • When the input's ID is nil, Create assigns a fresh typeid with the right prefix and the caller's pointer reflects it.
  • When the input's ID is pre-set, Create preserves it.
  • When the input's CreatedAt / UpdatedAt is zero, Create assigns `time.Now().UTC()` (within ±5s of the test's wall clock).
  • When CreatedAt / UpdatedAt is pre-set, Create preserves it.

All assertions are independent — a failure in one entity doesn't stop the suite. Each sub-test uses t.Run so backends can target a single entity with `-run TestAutoID/Role`.

func RunEmptyTenantContract added in v1.7.0

func RunEmptyTenantContract(t *testing.T, mk NewStore)

RunEmptyTenantContract documents what a query with an empty TenantID returns, on whichever backend it runs against.

This is not a test of desired behaviour. It is a record of actual behaviour, because the contract layer's tenant resolution refuses rather than passing an empty tenant through, and that refusal is only obviously correct while somebody remembers what empty does. If empty matches every row, this test says so in a place that cannot be forgotten.

If the four backends disagree with each other, that disagreement is the finding. Do not weaken the assertion until it satisfies all four.

func RunExpiryContract added in v1.7.0

func RunExpiryContract(t *testing.T, mk MakeStore)

RunExpiryContract asserts that an expired assignment is invisible to the role-resolution paths on the Check() hot path, while a not-yet-expired or never-expiring assignment stays visible.

The bug this guards: ListRolesForSubject and ListRolesForSubjectOnResource used to return every assignment row regardless of ExpiresAt, so a role granted "until Friday" kept granting access indefinitely once Friday passed, unless a separate cron sweep happened to call DeleteExpiredAssignments first. The fix pushes the expiry predicate into the query itself: `expires_at IS NULL OR expires_at > now`.

func RunJunctionIntegrityContract added in v1.7.0

func RunJunctionIntegrityContract(t *testing.T, mk MakeStore)

RunJunctionIntegrityContract asserts that the role/permission junction can't reference a permission that doesn't exist, and that deleting a permission fully scrubs the grants that pointed at it, so recreating a permission with the same natural key never silently hands its grants back to roles that lost them.

func RunLikeEscapeContract added in v1.7.0

func RunLikeEscapeContract(t *testing.T, mk MakeStore)

RunLikeEscapeContract asserts that a search term containing SQL LIKE metacharacters (% and _) is matched literally, not as a wildcard pattern.

The bug this guards: ListRoles (and every other Search filter built as `LIKE '%'||term||'%'`) passed the caller's search term straight into the pattern. A role named "100%" searched for with "100%" happened to still match, but searching "100_" would ALSO match it: the "_" wildcard matches any single character, including the literal "%" in the stored name, which is not what a caller typing an underscore meant. The fix escapes \, % and _ in the term before building the pattern and adds `ESCAPE '\'` to the query.

func RunListFiltersContract added in v1.7.0

func RunListFiltersContract(t *testing.T, mk MakeStore)

RunListFiltersContract asserts that every List*/Count* pair honours NamespacePath (an exact match) and NamespacePrefix (self or any "prefix/..." descendant), that assignment filters also cover ResourceType/ResourceID, that relation filters also cover SubjectRelation, that check-log filters also cover ResourceID, and that Count* reports the full matching row count regardless of Limit/Offset.

func RunNamespaceFilterContract added in v1.5.3

func RunNamespaceFilterContract(t *testing.T, mk MakeStore)

RunNamespaceFilterContract asserts that the namespace-scoped list queries on the Check() hot path correctly filter by a multi-element slice of namespace paths.

This is the regression contract for the bug where the SQL stores wrote:

q.Where("namespace_path IN (?)", namespacePaths)   // namespacePaths is []string

Grove's query builders bind one argument per "?" placeholder and do not expand slices, so the whole []string was bound to the single "?" and the driver (pgx / sqlite) rejected it — every Check() against a SQL store errored. The fix: postgres uses WhereArray("= ANY"); sqlite expands to IN (?, ?, …). The in-memory store iterates in Go and always satisfied this; this contract guards the SQL backends so the bug can't come back.

Each case seeds rows across the ancestor namespaces ["eng/platform", "eng", ""] plus one decoy row at a non-ancestor "other" namespace that must be excluded from the result.

func RunPolicyRenameUniquenessContract added in v1.7.0

func RunPolicyRenameUniquenessContract(t *testing.T, mk MakeStore)

RunPolicyRenameUniquenessContract proves both update calls refuse a rename onto a taken name with ErrDuplicatePolicy, leave the stored policy as it was, and accept the renames that are not duplicates.

func RunPolicyRoundTripContract added in v1.7.0

func RunPolicyRoundTripContract(t *testing.T, mk MakeStore)

RunPolicyRoundTripContract proves a populated policy survives create, update, and every read path the dashboard or the engine uses.

func RunPolicyVersionContract added in v1.7.0

func RunPolicyVersionContract(t *testing.T, mk MakeStore)

RunPolicyVersionContract proves UpdatePolicyIfVersion writes on a matching version, refuses a stale one without writing, tells not-found from stale, and lets exactly one of several racing writers win.

func RunRelationDeleteTupleContract added in v1.7.0

func RunRelationDeleteTupleContract(t *testing.T, mk MakeStore)

RunRelationDeleteTupleContract seeds two tuples that differ only in subject_relation, plus the same key in another namespace and another tenant. It deletes by the key with an empty subject relation and proves only the direct tuple is gone, then with "member" and proves only the subject set is gone. The other namespace and tenant survive both.

func RunRelationGetContract added in v1.7.0

func RunRelationGetContract(t *testing.T, mk MakeStore)

RunRelationGetContract proves GetRelation returns every field of the stored tuple, and returns ErrRelationNotFound both for an ID that was never stored and for an ID that belongs to another tenant.

func RunResourceTypeEmptyListsContract added in v1.7.0

func RunResourceTypeEmptyListsContract(t *testing.T, mk MakeStore)

RunResourceTypeEmptyListsContract proves a resource type whose relation and permission lists are nil can be created, updated, and read back.

func RunTenantIsolationContract added in v1.7.0

func RunTenantIsolationContract(t *testing.T, newStore NewStore)

RunTenantIsolationContract asserts that a store treats the tenant as part of the key on every by-ID operation.

The bug this guards: each by-ID method used to filter on the primary key alone, so any caller holding an ID from tenant A could read, overwrite or delete that row while acting as tenant B. Primary keys are typeids, which leak through logs, URLs and exports, so "you need the ID" was never a boundary.

The rule every backend has to satisfy: a by-ID call carrying the wrong tenant behaves exactly as if the row did not exist. It returns the same not-found sentinel a genuinely missing row returns, never a distinct "forbidden" error, because a distinct error would itself confirm that the ID exists somewhere. Writes leave the real row untouched.

func RunTenantMaintenanceContract added in v1.7.0

func RunTenantMaintenanceContract(t *testing.T, mk MakeStore)

RunTenantMaintenanceContract seeds two tenants, each with an expired and a live assignment and a check log entry either side of the cutoff, runs both tenant purges for t1, and proves only t1's expired assignment and old entry are gone while every t2 row survives. An empty tenant ID is an error and deletes nothing.

func RunUniquenessContract

func RunUniquenessContract(t *testing.T, mk MakeStore)

RunUniquenessContract asserts the documented uniqueness invariant across every entity type a Warden store persists:

  • Two entities sharing (tenant_id, namespace_path, slug|name) are rejected with the matching ErrDuplicate* sentinel.
  • Same slug|name across different namespaces is allowed.
  • Same slug|name across different tenants is allowed.

Backends call this from their own test files with a MakeStore factory (see autoid.go for the same pattern). Each subtest gets its own fresh store via the factory so tests don't interfere.

Types

type MakeStore

type MakeStore func(t *testing.T) (s store.Store, cleanup func())

MakeStore returns a fresh Store + a cleanup function. The cleanup runs even if the test fails. Backends that talk to a real database (postgres, mongo) typically wrap each call in its own database / collection / schema so tests stay isolated.

type NewStore added in v1.7.0

type NewStore func(t *testing.T) store.Store

NewStore builds a fresh Store for one sub-test. Backends register their own teardown with t.Cleanup, so the contract never has to unwind anything itself.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL