diff --git a/Makefile b/Makefile index 8b506df6ed..65e5b41ad5 100644 --- a/Makefile +++ b/Makefile @@ -561,7 +561,7 @@ endif # Note: we don't run zizmor in the lint target because it takes a while. CI # runs it explicitly. -lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint +lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes .PHONY: lint lint/site-icons: @@ -614,6 +614,11 @@ lint/actions/zizmor: . .PHONY: lint/actions/zizmor +# Verify api_key_scope enum contains all RBAC : values. +lint/check-scopes: coderd/database/dump.sql + go run ./scripts/check-scopes +.PHONY: lint/check-scopes + # All files generated by the database should be added here, and this can be used # as a target for jobs that need to run after the database is generated. DB_GEN_FILES := \ diff --git a/scripts/check-scopes/README.md b/scripts/check-scopes/README.md new file mode 100644 index 0000000000..4060ba9b07 --- /dev/null +++ b/scripts/check-scopes/README.md @@ -0,0 +1,43 @@ +# check-scopes + +Validates that the DB enum `api_key_scope` contains every `:` derived from `coderd/rbac/policy/RBACPermissions`. + +- Exits 0 when all scopes are present in `coderd/database/dump.sql`. +- Exits 1 and prints missing values with suggested `ALTER TYPE` statements otherwise. + +## Usage + +Ensure the schema dump is up-to-date, then run the check: + +```sh +make -B gen/db # forces DB dump regeneration +make lint/check-scopes +``` + +Or directly: + +```sh +go run ./tools/check-scopes +``` + +Optional flags: + +- `-dump path` — override path to `dump.sql` (default `coderd/database/dump.sql`). + +## Remediation + +When the tool reports missing values: + +1. Create a DB migration extending the enum, e.g.: + + ```sql + ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:view_insights'; + ``` + +2. Regenerate and re-run: + + ```sh + make -B gen/db && make lint/check-scopes + ``` + +3. Decide whether each new scope is public (exposed in the catalog) or internal-only (handled by the catalog task). diff --git a/scripts/check-scopes/main.go b/scripts/check-scopes/main.go new file mode 100644 index 0000000000..310687bc0d --- /dev/null +++ b/scripts/check-scopes/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "regexp" + "sort" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +// defaultDumpPath is the repo-relative path to the generated schema dump. +const defaultDumpPath = "coderd/database/dump.sql" + +var dumpPathFlag = flag.String("dump", defaultDumpPath, "path to dump.sql (defaults to coderd/database/dump.sql)") + +func main() { + flag.Parse() + + want := expectedFromRBAC() + have, err := enumValuesFromDump(*dumpPathFlag) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "check-scopes: error reading dump: %v\n", err) + os.Exit(2) + } + + // Compute missing: want - have + var missing []string + for k := range want { + if _, ok := have[k]; !ok { + missing = append(missing, k) + } + } + sort.Strings(missing) + + if len(missing) == 0 { + _, _ = fmt.Println("check-scopes: OK — all RBAC : values exist in api_key_scope enum") + return + } + + _, _ = fmt.Fprintln(os.Stderr, "check-scopes: missing enum values:") + for _, m := range missing { + _, _ = fmt.Fprintf(os.Stderr, " - %s\n", m) + } + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintln(os.Stderr, "To fix: add a DB migration extending the enum, e.g.:") + for _, m := range missing { + _, _ = fmt.Fprintf(os.Stderr, " ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS '%s';\n", m) + } + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintln(os.Stderr, "Also decide if each new scope is public (exposed in the catalog) or internal-only (catalog task).") + os.Exit(1) +} + +// expectedFromRBAC returns the set of : pairs derived from RBACPermissions. +func expectedFromRBAC() map[string]struct{} { + want := make(map[string]struct{}) + for resource, def := range policy.RBACPermissions { + if resource == policy.WildcardSymbol { + // Ignore wildcard entry; it has no concrete : pairs. + continue + } + for action := range def.Actions { + key := resource + ":" + string(action) + want[key] = struct{}{} + } + } + return want +} + +// enumValuesFromDump parses dump.sql and extracts all literals from the +// `CREATE TYPE api_key_scope AS ENUM (...)` block. +func enumValuesFromDump(path string) (map[string]struct{}, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + const enumHead = "CREATE TYPE api_key_scope AS ENUM (" + litRe := regexp.MustCompile(`'([^']+)'`) + + values := make(map[string]struct{}) + inEnum := false + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if !inEnum { + if strings.Contains(line, enumHead) { + inEnum = true + } + continue + } + if strings.HasPrefix(line, ");") { + // End of enum block + return values, nil + } + // Collect single-quoted literals on this line. + for _, m := range litRe.FindAllStringSubmatch(line, -1) { + if len(m) > 1 { + values[m[1]] = struct{}{} + } + } + } + if err := s.Err(); err != nil { + return nil, err + } + if !inEnum { + return nil, xerrors.New("api_key_scope enum block not found in dump") + } + return values, nil +}