Files
coder/coderd/experiments/dbstore_test.go
T
Thomas Kosiewski a0b00b5e4b feat: store experiment rules with revisions and audit types (#29898)
Slice 2 of 4 for runtime experiment rules: persist one rule per
experiment in `site_configs`, with revisions that only increase, and add
an audit record type. Nothing calls this code yet, so behavior does not
change.

## What's in it

- **Queries** in `siteconfig.sql`, all checked by dbauthz against
`ResourceDeploymentConfig`:
- `GetExperimentRules` (read) lists `experiment_rule:` keys with
`starts_with`, not `LIKE`, because `_` is a `LIKE` wildcard.
  - `GetExperimentRule` (read) reads one row.
  - `UpsertExperimentRule` (update) writes one row.
- **`experiments.WriteRule(ctx, db, actor, ex, rule,
expectedRevision)`** validates the rule first, then does everything in
one `READ COMMITTED` transaction:
  - It takes an advisory lock for that experiment, waiting at most 5 s.
  - It reads the current row; a missing row counts as revision 0.
- It returns `*RevisionConflictError` when the expected revision is
stale.
- An identical mode and condition writes nothing and returns
`changed=false`. The first `inherit` on a missing row is a change and
stores revision 1.
- Otherwise it writes `revision+1` with the actor and time, and returns
the old and new rule as read and written inside the transaction.
- **`experiments.NewDBStore(db)`** implements the evaluator's `Store`.
It reads rules by prefix, plus the user's id, username, email, explicit
site roles, member org names, and `<org>/<group>` group names (including
`<org>/Everyone`). It runs as the system.
- **Audit:**
- A new `resource_type` enum value `experiment_rule` (migration
`000602`; the down migration is a comment because PostgreSQL cannot drop
enum values).
- A new struct `database.ExperimentRule`. `experiments.AuditRecord`
derives its ID from the experiment name, so audit history groups by
experiment.
- In the audit table, `experiment`, `mode` `revision` and `condition`
are tracked (`ActionTrack`), so the audit diff shows the old and new
condition. In Enterprise, the slog audit backend also writes that diff
to the server log. Admins and auditors can already read the condition
through the rules API.
  - The audit page filter includes the new resource type.

## Testing

- `WriteRule` and `NewDBStore` tests run against real PostgreSQL. They
cover stale revisions after a reset and a recreate, two concurrent
writers with the same expected revision (exactly one wins), no-ops, and
rollback after a forced error.
- dbauthz method tests and an audit diff test that shows both condition
texts.

Remote dogfood UAT through Coder Agents passed the upgrade from slice 1
(existing state kept, enum value added), a fresh install, unchanged
`/api/v2/experiments`, and the `WriteRule` tests under `-race`. The
licensed audit-page check could not run: the test workspace has no
premium license. That round tested `015979ca96`. The commit after it
only adds `AuditRecord`, and a follow-up UAT round will cover the final
head.

> [!NOTE]
> Migration `000604` was the next free number when this was written.
Recheck it before merging.

## Stack

1. #29869: evaluator library
2. **This PR:** storage and audit types
3. Enforcement and discovery (`/api/v2/experiments`, meta tag, chatd
`mcp-tool-search`, ruleguard)
4. Control plane (experimental API, `coder exp experiment-rules` CLI,
docs)

> [!NOTE]
> This PR was written by an AI agent (Xum).

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-opus-5-5` • Thinking: `high`_

---------

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-09-29 12:22:59 +00:00

128 lines
5.3 KiB
Go

package experiments_test
import (
"slices"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/experiments"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// newAuthzDBStore returns a DB store behind dbauthz, as in production. The
// test contexts carry no actor, so reads succeed only if the store reads
// as the system.
func newAuthzDBStore(t *testing.T, db database.Store) experiments.Store {
t.Helper()
authzDB := dbauthz.New(db, rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()),
slogtest.Make(t, nil), coderdtest.AccessControlStorePointer())
return experiments.NewDBStore(authzDB)
}
func TestDBStore(t *testing.T) {
t.Parallel()
t.Run("RulesByPrefixOnly", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
db, _ := dbtestutil.NewDB(t)
rules := map[string]string{
string(scoped): `{"mode":"on","revision":1}`,
// Unknown experiments and undecodable values are returned so
// the Evaluator can ignore or fail closed on them.
"not-a-real-experiment": `{"mode":"on","revision":1}`,
"broken": `{not json`,
}
for experiment, value := range rules {
require.NoError(t, db.UpsertExperimentRule(ctx, database.UpsertExperimentRuleParams{Experiment: experiment, Value: value}))
}
// Keys that resemble the prefix. '_' is a LIKE wildcard, so
// experimentXrule: would match a LIKE-based filter.
for _, key := range []string{"experiment_rule", "experimentXrule:example", "other_experiment_rule:example"} {
require.NoError(t, db.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{Key: key, Value: `{"mode":"on","revision":1}`}))
}
got, err := newAuthzDBStore(t, db).Rules(ctx)
require.NoError(t, err)
want := make(map[codersdk.Experiment]experiments.StoredRule, len(rules))
for experiment, value := range rules {
want[codersdk.Experiment(experiment)] = experiments.StoredRule{Value: []byte(value)}
}
require.Equal(t, want, got)
})
t.Run("UserAttributes", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{RBACRoles: []string{rbac.RoleAuditor().String()}})
orgA := dbgen.Organization(t, db, database.Organization{})
orgB := dbgen.Organization(t, db, database.Organization{})
// The user is not a member of orgC or its group.
orgC := dbgen.Organization(t, db, database.Organization{})
// Organization creation adds the Everyone group in coderd, not in
// dbgen.
for _, org := range []database.Organization{orgA, orgB, orgC} {
_, err := db.InsertAllUsersGroup(ctx, org.ID)
require.NoError(t, err)
}
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: orgA.ID})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: orgB.ID})
devs := dbgen.Group(t, db, database.Group{OrganizationID: orgB.ID})
dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: user.ID, GroupID: devs.ID})
dbgen.Group(t, db, database.Group{OrganizationID: orgC.ID})
// A soft-deleted organization keeps its last member and its
// Everyone group. Its name can be reused, so neither may reach
// conditions.
orgGone := dbgen.Organization(t, db, database.Organization{})
_, err := db.InsertAllUsersGroup(ctx, orgGone.ID)
require.NoError(t, err)
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: orgGone.ID})
require.NoError(t, db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{
ID: orgGone.ID,
UpdatedAt: dbtime.Now(),
}))
got, err := newAuthzDBStore(t, db).UserAttributes(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, user.ID.String(), got.ID)
require.Equal(t, user.Username, got.Username)
require.Equal(t, user.Email, got.Email)
require.Equal(t, []string{rbac.RoleAuditor().String()}, got.Roles)
require.ElementsMatch(t, []string{orgA.Name, orgB.Name}, got.Organizations)
require.ElementsMatch(t, []string{
orgA.Name + "/Everyone",
orgB.Name + "/Everyone",
orgB.Name + "/" + devs.Name,
}, got.Groups)
// Lists are sorted so conditions that index or compare whole lists
// do not depend on query row order.
require.True(t, slices.IsSorted(got.Organizations), got.Organizations)
require.True(t, slices.IsSorted(got.Groups), got.Groups)
})
t.Run("SortedRoles", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{RBACRoles: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleAuditor().String()}})
got, err := newAuthzDBStore(t, db).UserAttributes(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, []string{rbac.RoleAuditor().String(), rbac.RoleTemplateAdmin().String()}, got.Roles)
})
}