mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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>
128 lines
5.3 KiB
Go
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)
|
|
})
|
|
}
|