check-safety/check_rbac.go
Alex Dunmow 2c7fe9aa62 fix: model orchestrator + cli as first-class scan targets (WO-WZ-023)
- resolveScanRoots recognizes the orchestrator layout (backend/go.mod +
  frontend/src) so backendDir resolves to backend/ — fixes the go.mod check,
  classifies the frontend as a standalone app (not a plugin), and routes tsc
  through the plain typecheck. Clears three orchestrator false-positives.
- check 2 (RBAC): only require MethodRoles for proto services a target actually
  serves (mounted connect handler). The orchestrator carries but does not serve
  the blockninja.v1 surface (714 false 'missing' -> 0). Exclude cli
  (definitions-only, vendors a registry client) from check 2.
- Skip buf-generated gen/ output in the placeholder check (real proto enum
  COMING_SOON values are not TODO debt).
- Normalize the color allowlist path so theme-card.tsx swatch previews match
  regardless of scan root.
- Whitelist the gitignored, tool-regenerated styles/package-lock.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:39:47 +08:00

303 lines
11 KiB
Go

package main
import (
"path/filepath"
"sort"
"strings"
)
// coreSDKModulePath is the shared Go SDK that carries the canonical proto
// definitions (blockninja.v1, orchestrator.v1, helpdesk.v1) consumed by the
// serving backends. It has no connect server and no RBAC interceptor of its
// own — RBAC is enforced in the repos that actually serve these procedures
// (cms for blockninja.v1, orchestrator for orchestrator.v1), where check 2
// runs against their interceptors. Scanning the SDK in isolation would flag
// every shared procedure as "missing"; that is a not-applicable result, not a
// finding.
const coreSDKModulePath = "git.dev.alexdunmow.com/block/core"
// definitionsOnlyModulePaths are Go modules that carry proto service/rpc
// definitions but serve NONE of them — RBAC is enforced in the repos that
// actually serve the procedures (cms for blockninja.v1, orchestrator for
// orchestrator.v1). Scanning them in isolation would flag every procedure as
// "missing from MethodRoles", a not-applicable result rather than a finding.
//
// - core: the shared proto SDK.
// - cli: the ninja developer CLI (WO-WZ-023). It vendors
// orchestrator/v1/plugin_registry.proto to generate a CLIENT for the
// registry; it has no server and no RBAC interceptor.
var definitionsOnlyModulePaths = map[string]bool{
coreSDKModulePath: true,
"git.dev.alexdunmow.com/block/cli": true,
}
// isDefinitionsOnlySDKTarget reports whether the backend target carries proto
// but serves nothing, so it has neither an RBAC interceptor nor a designated
// serving package prefix. Such a target is out of scope for check 2 — see
// definitionsOnlyModulePaths.
func isDefinitionsOnlySDKTarget(target backendScanTarget) bool {
if len(target.allowedPackagePrefixes) > 0 {
return false // a designated serving backend (cms/orchestrator)
}
if fileExists(filepath.Join(target.root, "internal/rbac/interceptor.go")) {
return false // has a serving RBAC interceptor
}
modPath, err := readModulePath(target.root)
if err != nil {
return false
}
return definitionsOnlyModulePaths[modPath]
}
func init() {
register(Check{
Seq: 20,
ID: "2",
Title: "RPC methods registered in RBAC interceptor",
Run: func(ctx *ScanContext, rep *Reporter) {
protoMethods := make([]string, 0)
rbacMethods := make(map[string]string)
protoMethodSet := make(map[string]bool)
// protoMethodSetAll holds every declared proto procedure (unfiltered
// by served-surface). Stale detection compares RBAC entries against
// this full set so an RBAC role for a real-but-unmounted proto method
// (e.g. orchestrator.v1.DNSService, declared + roled but not served)
// is not mistaken for a dangling entry.
protoMethodSetAll := make(map[string]bool)
requiresRPCDiscovery := false
var rbacSummaries []rbacTargetSummary
var definitionsOnlySDKLabels []string
for _, target := range ctx.backendTargets {
// The shared proto SDK (core) serves nothing — RBAC is enforced
// in the consuming repos, where this check runs. Don't fold its
// procedures into the missing-set; check 2 is not applicable.
if isDefinitionsOnlySDKTarget(target) {
definitionsOnlySDKLabels = append(definitionsOnlySDKLabels, target.displayOrRoot())
continue
}
rbacFile := filepath.Join(target.root, "internal/rbac/interceptor.go")
hasCoreRBAC := fileExists(rbacFile)
summary := rbacTargetSummary{
label: target.displayOrRoot(),
roleByMethod: make(map[string]string),
}
if hasCoreRBAC {
requiresRPCDiscovery = true
summary.requiresRPCs = true
targetProtoMethods, err := extractBufProcedures(target.protoRoot, target.allowedPackagePrefixes...)
if err != nil {
rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
}
// Record the FULL declared surface for stale detection, then
// scope the missing-RBAC check to the procedures this target
// actually SERVES. A serving backend may carry proto for
// services it never mounts (for frontend codegen); those are
// not real RBAC gaps. See extractServedServiceKeys.
summary.protoMethodsAll = append(summary.protoMethodsAll, targetProtoMethods...)
for _, pm := range targetProtoMethods {
protoMethodSetAll[pm] = true
}
servedKeys, err := extractServedServiceKeys(target.root)
if err != nil {
rep.Fatal("failed to scan served handlers for %s: %v", target.displayOrRoot(), err)
}
targetProtoMethods = filterProceduresToServed(targetProtoMethods, servedKeys)
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
targetRBACMethods, err := extractRBACMethodRoles(rbacFile)
if err != nil {
rep.Fatal("failed to parse RBAC interceptor for %s: %v", target.displayOrRoot(), err)
}
for method, role := range targetRBACMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
continue
}
if len(target.allowedPackagePrefixes) > 0 {
requiresRPCDiscovery = true
summary.requiresRPCs = true
}
targetProtoMethods, err := extractBufProcedures(target.root)
if err != nil {
rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
}
// Same served-surface scoping as the interceptor branch above:
// keep only procedures whose service this target actually mounts.
// This is the case the orchestrator hits — its RBAC interceptor
// lives under backend/ (so hasCoreRBAC is false at this root),
// and it carries the full blockninja.v1 + helpdesk.v1 proto while
// serving only orchestrator.v1. The full surface is retained for
// stale detection.
summary.protoMethodsAll = append(summary.protoMethodsAll, targetProtoMethods...)
for _, pm := range targetProtoMethods {
protoMethodSetAll[pm] = true
}
servedKeys, err := extractServedServiceKeys(target.root)
if err != nil {
rep.Fatal("failed to scan served handlers for %s: %v", target.displayOrRoot(), err)
}
targetProtoMethods = filterProceduresToServed(targetProtoMethods, servedKeys)
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
targetRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil {
rep.Fatal("failed to parse RBAC roles for %s: %v", target.displayOrRoot(), err)
}
for method, role := range targetRoleMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
}
for _, target := range ctx.pluginTargets {
summary := rbacTargetSummary{
label: target.display,
roleByMethod: make(map[string]string),
}
pluginProtoMethods, err := extractBufProcedures(target.root)
if err != nil {
rep.Fatal("failed to parse plugin proto files for %s: %v", target.display, err)
}
// Plugins serve every procedure they declare — no served-surface
// filtering. The full and served sets are identical here.
summary.protoMethods = append(summary.protoMethods, pluginProtoMethods...)
summary.protoMethodsAll = append(summary.protoMethodsAll, pluginProtoMethods...)
for _, pm := range pluginProtoMethods {
protoMethodSetAll[pm] = true
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
pluginRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil {
rep.Fatal("failed to parse plugin RBAC roles for %s: %v", target.display, err)
}
for method, role := range pluginRoleMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
}
if len(protoMethods) == 0 {
if requiresRPCDiscovery {
rep.Fatal("no RPC procedures found in proto sources or generated Connect files")
}
if len(definitionsOnlySDKLabels) > 0 {
rep.Skip("%s — definitions-only proto SDK; RBAC enforced in the serving repos (cms/orchestrator), not here", strings.Join(definitionsOnlySDKLabels, ", "))
} else {
rep.Skip("no proto-backed RPC procedures found in scanned target(s)")
}
} else {
var missing []string
for _, m := range protoMethods {
if isExcludedServiceMethod(m) {
continue
}
if _, ok := rbacMethods[m]; !ok {
missing = append(missing, m)
}
}
// Stale = an RBAC entry that matches no DECLARED proto method at
// all (compare against the full surface, not the served subset —
// a role for a real-but-unmounted proto method is intentional, not
// dangling).
var stale []string
for m := range rbacMethods {
if !protoMethodSetAll[m] {
if isExcludedServiceMethod(m) {
continue
}
stale = append(stale, m)
}
}
sort.Strings(stale)
for i := range rbacSummaries {
summary := &rbacSummaries[i]
for _, method := range summary.protoMethods {
if isExcludedServiceMethod(method) {
continue
}
if _, ok := summary.roleByMethod[method]; !ok {
summary.missingCount++
}
}
for method := range summary.roleByMethod {
if isExcludedServiceMethod(method) {
continue
}
// Compare against the full declared surface (see the
// global stale note above), not the served subset.
if !containsString(summary.protoMethodsAll, method) {
summary.staleCount++
}
}
}
for _, summary := range rbacSummaries {
if len(summary.protoMethods) == 0 {
if summary.requiresRPCs {
rep.Fail("%s — no RPC procedures found", summary.label)
} else {
rep.OK("%s — no proto-backed RPC procedures", summary.label)
}
continue
}
rep.OK("%s — %d RPC methods discovered", summary.label, len(summary.protoMethods))
if summary.missingCount > 0 {
rep.Findingf("missing RBAC entries: %d", summary.missingCount)
}
if summary.staleCount > 0 {
rep.Findingf("stale RBAC entries: %d", summary.staleCount)
}
}
if len(missing) > 0 {
rep.Fail("%d RPC method(s) NOT in MethodRoles (will get permission_denied)", len(missing))
for _, m := range missing {
rep.Findingf("- %s", m)
}
}
if len(stale) > 0 {
rep.Warn("%d RBAC entries have no matching proto method (stale?)", len(stale))
for _, m := range stale {
rep.Findingf("- %s", m)
}
}
if len(missing) == 0 && len(stale) == 0 {
rep.OK("All %d RPC methods are registered in MethodRoles", len(protoMethods))
} else if len(missing) == 0 {
rep.OK("All %d RPC methods are registered (but %d stale entries exist)", len(protoMethods), len(stale))
}
}
},
})
}