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)) } } }, }) }