- 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>
303 lines
11 KiB
Go
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))
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|