Skip to content

Commit f7bfa66

Browse files
feat(doctor): judge a solution's permissions, and report a shell with no app (#1221)
* feat(doctor): judge a solution's permissions, by the reader that owns them The missing-permission class — a role promising authority nothing backs — is already caught by core: reading the contract through modulecontract .Load refuses "role installer grants documents:read, which is neither within the module's own scope ceilings nor a scope one of its bindings spells out". Nothing was running that over solutions, so it was found at the first refused call instead of before install. It runs here now, and a test pins the message. Adds consumed_module_without_binding as a WARNING: a module in api.consumes the contract declares no binding for. Correct when the module is reached with the viewer's own credential — wiki reaches robin, tracing and annotations that way and says so — and a bug when the solution calls it on a person's behalf. The two are indistinguishable from these documents, so it is surfaced rather than failed. A first draft also failed any binding no role named by id. It reported wiki and proposals — the two longest-running solutions — as broken, which is how the second mechanism was found: a role reaches another module's authority by declaring that module's resource KINDS, with no RoleBinding at all. That check is gone, and the source says what cannot be answered from a directory: the authoritative version needs Contract.Resolve(values) against a composition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(doctor): report a page shell declared with no app behind it Conforming the fleet to one shape added the `web` shell's MANIFEST to solutions whose page had never been written — writing one would have been inventing product in a conforming change. lastlogin-go and robin-nest are in that state on main today: correctly shaped, and `codefly run service web` has nothing to start. A declared service with no source is invisible to every other reading. Each manifest is valid, every gate passes, and the first sign is a run that does nothing. So it is reported — as a warning, because the solution is not wrong and nothing breaks until someone runs the shell. wiki, proposals and robin-builder ship real shells and are silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ad5d7a4 commit f7bfa66

6 files changed

Lines changed: 218 additions & 8 deletions

File tree

‎cmd/doctor_solution.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ Every rule is one that silently broke something. The two worth knowing:
5555
renders into a mesh allow rule straight to that module's
5656
service, past the check.
5757
58+
Permissions are judged by reading the contract through Core's own loader,
59+
which refuses a role granting a scope no binding backs and no ceiling covers
60+
("role X grants documents:read, which is neither within the module's own scope
61+
ceilings nor a scope one of its bindings spells out"). That is the
62+
missing-permission class, caught before install rather than at the first
63+
refused call. What a composition's values resolve each binding to is NOT
64+
judged here: that needs the composition, not the directory.
65+
5866
Exit codes:
5967
0 every judged solution conforms (warnings allowed)
6068
1 at least one check failed, or the command itself failed
@@ -71,7 +79,8 @@ module_contract_unreadable, owner_aware_missing, module_package_missing,
7179
module_package_unreadable, module_package_publishes_shell,
7280
frontend_declaration_missing, frontend_declaration_unreadable,
7381
page_shell_missing, module_interface_missing, entry_endpoint_not_exported,
74-
page_shell_exported_too_widely.`,
82+
page_shell_exported_too_widely, consumed_module_without_binding,
83+
page_shell_has_no_app.`,
7584
Example: ` # Judge every solution in the workspace.
7685
codefly doctor solution
7786

‎cmd/doctor_solution_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func TestEveryDiagnosticCodeIsDocumented(t *testing.T) {
5252
solutionshape.CodeFrontendMissing, solutionshape.CodeFrontendUnreadable,
5353
solutionshape.CodePageServiceMissing, solutionshape.CodeInterfaceMissing,
5454
solutionshape.CodeEntryNotExported, solutionshape.CodePageExportedTooWidely,
55+
solutionshape.CodeConsumedWithoutBinding, solutionshape.CodePageShellHasNoApp,
5556
} {
5657
if !strings.Contains(DoctorSolutionCmd.Long, code) {
5758
t.Errorf("diagnostic code %q is not documented in the command's help", code)

‎docs/cli-reference.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2505,6 +2505,14 @@ Every rule is one that silently broke something. The two worth knowing:
25052505
renders into a mesh allow rule straight to that module's
25062506
service, past the check.
25072507
2508+
Permissions are judged by reading the contract through Core's own loader,
2509+
which refuses a role granting a scope no binding backs and no ceiling covers
2510+
("role X grants documents:read, which is neither within the module's own scope
2511+
ceilings nor a scope one of its bindings spells out"). That is the
2512+
missing-permission class, caught before install rather than at the first
2513+
refused call. What a composition's values resolve each binding to is NOT
2514+
judged here: that needs the composition, not the directory.
2515+
25082516
Exit codes:
25092517
0 every judged solution conforms (warnings allowed)
25102518
1 at least one check failed, or the command itself failed
@@ -2521,7 +2529,8 @@ module_contract_unreadable, owner_aware_missing, module_package_missing,
25212529
module_package_unreadable, module_package_publishes_shell,
25222530
frontend_declaration_missing, frontend_declaration_unreadable,
25232531
page_shell_missing, module_interface_missing, entry_endpoint_not_exported,
2524-
page_shell_exported_too_widely.
2532+
page_shell_exported_too_widely, consumed_module_without_binding,
2533+
page_shell_has_no_app.
25252534
```
25262535

25272536
```

‎docs/commands.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2465,13 +2465,30 @@ Every rule is one that silently broke something. The two worth knowing:
24652465
| | |
24662466
|---|---|
24672467
| `owner_aware_missing` | The authority issues a person a capability at a solution **only** when its module contract declares `owner_aware: true`, and refuses by name otherwise. Without it the solution receives no owner-selected scope and can act for nobody — and nothing fails at build time to tell you. |
2468+
| `consumed_module_without_binding` | A module named in `api.consumes` that the contract declares no binding for. A **warning**: correct when the module is reached with the viewer's own credential (wiki reaches robin, tracing and annotations that way), a bug when the solution calls it on a person's behalf — and the two look identical until a call is refused. |
24682469
| `module_edge_declared` | A solution reaches every module through the **host's door** — a saas host's gateway and frontend, or the authority's hop beside the authority interface. A `service-dependencies` entry naming anything else renders into a mesh allow rule straight to that module's service, past the authority's check. |
24692470

24702471
Exit codes: `0` every judged solution conforms (warnings allowed), `1` at least
24712472
one check failed. `--json` prints a versioned report per module
24722473
(`{schema_version, module, dir, is_solution, status, checks[]}`), so a
24732474
consumer repository can gate on it instead of hand-rolling the equivalent.
24742475

2476+
**Permissions.** The contract is read through Core's own loader, which refuses
2477+
a role granting a scope no binding backs and no ceiling covers — *"role
2478+
`installer` grants `documents:read`, which is neither within the module's own
2479+
scope ceilings nor a scope one of its bindings spells out"*. That is the
2480+
missing-permission class, caught before install rather than at the first
2481+
refused call. What a composition's values resolve each binding to is not
2482+
judged: that needs the composition, not the directory.
2483+
2484+
`page_shell_has_no_app` is worth knowing about: conforming the fleet to one
2485+
shape added the `web` shell's *manifest* to solutions whose page had never been
2486+
written, because writing one would have been inventing product in a conforming
2487+
change. Those solutions are correctly shaped and `codefly run service web` has
2488+
nothing to start on them. A declared service with no source is otherwise
2489+
invisible — every manifest reads fine, and the first sign is a run that does
2490+
nothing.
2491+
24752492
A warning is not a failure: it names something worth seeing that does not make
24762493
the solution wrong — publishing the page's shell, a headless solution with no
24772494
page declaration, a solution with no shell at all.

‎pkg/solutionshape/check.go‎

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const (
7676
CodePageServiceMissing = "page_shell_missing"
7777
CodeInterfaceMissing = "module_interface_missing"
7878
CodeEntryNotExported = "entry_endpoint_not_exported"
79+
CodeConsumedWithoutBinding = "consumed_module_without_binding"
80+
CodePageShellHasNoApp = "page_shell_has_no_app"
7981
CodePageExportedTooWidely = "page_shell_exported_too_widely"
8082
)
8183

@@ -124,10 +126,10 @@ func Inspect(moduleName, dir string) *Report {
124126
return report
125127
}
126128

127-
report.checkManifest(raw)
129+
consumed := report.checkManifest(raw)
128130
module := report.checkModule(dir)
129131
report.checkEntryService(dir)
130-
report.checkContract(dir)
132+
report.checkContract(dir, consumed)
131133
report.checkPackage(dir)
132134
report.checkFrontend(dir)
133135
if module != nil {
@@ -139,13 +141,13 @@ func Inspect(moduleName, dir string) *Report {
139141
// checkManifest holds the solution manifest to the reader every render, sync
140142
// and authority derivation actually uses, then to the one rule those readers
141143
// do not enforce: a consumed entry with no route name.
142-
func (r *Report) checkManifest(raw []byte) {
144+
func (r *Report) checkManifest(raw []byte) []string {
143145
loaded, err := manifest.LoadConsumption(raw)
144146
if err != nil {
145147
r.fail(CodeManifestUnreadable,
146148
fmt.Sprintf("%s is not readable by core's consumption reader: %v", manifest.FileName, err),
147149
"every render, sync and authority derivation reads the manifest this way, so nothing can resolve what this solution consumes")
148-
return
150+
return nil
149151
}
150152
r.ok(CodeManifestUnreadable, fmt.Sprintf("%s declares %d consumed binding(s)", manifest.FileName, len(loaded.API.Consumes)))
151153

@@ -158,12 +160,21 @@ func (r *Report) checkManifest(raw []byte) {
158160
r.fail(CodeConsumedAPIUnnamed,
159161
fmt.Sprintf("api.consumes entry %q names no route (`as`)", consumed.ID),
160162
"give it `as: <name>`; the runtime addresses the module by that name and an entry without one is unroutable")
161-
return
163+
return nil
162164
}
163165
}
164166
if len(loaded.API.Consumes) > 0 {
165167
r.ok(CodeConsumedAPIUnnamed, "every consumed API names its route")
166168
}
169+
seen := map[string]bool{}
170+
var modules []string
171+
for index := range loaded.API.Consumes {
172+
if module := loaded.API.Consumes[index].Module; module != "" && !seen[module] {
173+
seen[module] = true
174+
modules = append(modules, module)
175+
}
176+
}
177+
return modules
167178
}
168179

169180
// checkModule holds the module manifest's service-entry, which is load-bearing
@@ -189,6 +200,7 @@ func (r *Report) checkModule(dir string) *resources.Module {
189200

190201
if hasService(module, PageService) {
191202
r.ok(CodePageServiceMissing, "the page's shell is declared")
203+
r.checkPageShellHasAnApp(dir)
192204
} else {
193205
r.warn(CodePageServiceMissing,
194206
fmt.Sprintf("the module declares no %q service", PageService),
@@ -265,7 +277,7 @@ func (r *Report) checkEntryService(dir string) {
265277
}
266278

267279
// checkContract holds the contract to the declaration that fails silently.
268-
func (r *Report) checkContract(dir string) {
280+
func (r *Report) checkContract(dir string, consumed []string) {
269281
if _, err := os.Stat(filepath.Join(dir, modulecontract.FileName)); errors.Is(err, os.ErrNotExist) {
270282
r.fail(CodeContractMissing, fmt.Sprintf("no %s", modulecontract.FileName),
271283
"it declares the principal every credential the solution mints is issued to; without it the solution has no declared identity to issue against")
@@ -278,6 +290,7 @@ func (r *Report) checkContract(dir string) {
278290
return
279291
}
280292
r.ok(CodeContractMissing, fmt.Sprintf("the contract declares principal %q", contract.Principal))
293+
r.checkConsumedModulesHaveAuthority(consumed, contract)
281294

282295
// The one that fails silently, and the reason this checker exists.
283296
if contract.OwnerAware {
@@ -454,3 +467,88 @@ func interfaceKey(reference string) string {
454467
}
455468
return key
456469
}
470+
471+
// WHAT IS DELIBERATELY NOT CHECKED HERE, and why it would be wrong to try.
472+
//
473+
// "Can a person holding role R actually call module M?" is the
474+
// missing-permission question, and it cannot be answered from these documents
475+
// alone. A role reaches another module's authority two ways: by naming a
476+
// binding in role.Bindings, and by declaring that module's resource kinds in
477+
// role.Scopes — which is how wiki and proposals do it, granting `documents`
478+
// and `documents.collection` with no RoleBinding at all. A binding's own kinds
479+
// are composition SLOTS, so which kinds it covers is not known until the
480+
// composition supplies values.
481+
//
482+
// A first draft of this package failed any binding no role named by id. It
483+
// reported wiki and proposals — the two solutions that have been running
484+
// longest — as broken, which is how the second mechanism was found. The real
485+
// answer needs modulecontract.Contract.Resolve(values) against a composition's
486+
// configuration, which is a composition-scoped check and not a
487+
// directory-scoped one.
488+
489+
// checkConsumedModulesHaveAuthority reports a module the solution consumes for
490+
// which the contract declares no binding. This is NOT always wrong — a module
491+
// reached with the viewer's own credential, or on nobody's behalf, asks for no
492+
// binding — which is why it warns rather than fails. It is reported because
493+
// the two declarations live in different files, so nothing else puts them side
494+
// by side, and "the binding was never added" and "this one needs none" look
495+
// identical until a call is refused.
496+
func (r *Report) checkConsumedModulesHaveAuthority(consumes []string, contract *modulecontract.Contract) {
497+
if len(consumes) == 0 {
498+
return
499+
}
500+
bound := map[string]bool{}
501+
for i := range contract.Bindings {
502+
for _, audience := range contract.Bindings[i].Calls {
503+
for _, call := range audience {
504+
bound[call] = true
505+
}
506+
}
507+
bound[contract.Bindings[i].ID] = true
508+
}
509+
var unbound []string
510+
for _, module := range consumes {
511+
if !bound[module] {
512+
unbound = append(unbound, module)
513+
}
514+
}
515+
if len(unbound) == 0 {
516+
r.ok(CodeConsumedWithoutBinding, "every consumed module has a binding")
517+
return
518+
}
519+
r.warn(CodeConsumedWithoutBinding,
520+
fmt.Sprintf("consumed with no binding in the contract: %s", strings.Join(unbound, ", ")),
521+
"correct when the module is reached with the VIEWER'S own credential or on nobody's behalf; a bug when the solution calls it on a person's behalf, and then the call is refused at runtime with nothing failing earlier. Confirm each one")
522+
}
523+
524+
// checkPageShellHasAnApp reports a shell that is declared and has no app
525+
// behind it — a service.codefly.yaml naming a Next.js service with no
526+
// package.json beside it.
527+
//
528+
// It is a real state and not a hypothetical: conforming the fleet to one shape
529+
// added the shell's MANIFEST to several solutions whose page had never been
530+
// written, because writing one would have been inventing product in a
531+
// conforming change. The declaration is right and the gap is real, so
532+
// `codefly run service web` has nothing to start on those.
533+
//
534+
// A warning, not a failure: the solution is correctly shaped, and nothing is
535+
// broken until someone runs the shell. It is reported because a declared
536+
// service with no source is otherwise invisible — every manifest reads fine,
537+
// and the first sign is a run that does nothing.
538+
func (r *Report) checkPageShellHasAnApp(dir string) {
539+
shell := filepath.Join(dir, "services", PageService)
540+
if _, err := os.Stat(filepath.Join(shell, "service.codefly.yaml")); err != nil {
541+
// No manifest at all is the module declaring a service it does not
542+
// have, which the entry-service rules already speak to.
543+
return
544+
}
545+
for _, marker := range []string{"package.json", "next.config.ts", "next.config.js"} {
546+
if _, err := os.Stat(filepath.Join(shell, marker)); err == nil {
547+
r.ok(CodePageShellHasNoApp, "the page's shell has an app behind it")
548+
return
549+
}
550+
}
551+
r.warn(CodePageShellHasNoApp,
552+
fmt.Sprintf("the %q shell is declared with no app behind it", PageService),
553+
"`codefly run service "+PageService+"` has nothing to start. Write the shell, or drop the service from module.codefly.yaml — a declaration with no source is the one state neither reading the manifests nor running the gates reveals")
554+
}

‎pkg/solutionshape/check_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package solutionshape
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78
)
89

@@ -320,3 +321,78 @@ endpoints:
320321
t.Fatal("a backend that presents no identity was accepted")
321322
}
322323
}
324+
325+
// A consumed module with no binding is reported, but as a warning: it is
326+
// correct when the module is reached with the viewer's own credential, which
327+
// is what wiki does for robin, tracing and annotations. Failing it would fail
328+
// the reference solution.
329+
func TestAConsumedModuleWithNoBindingWarnsRatherThanFails(t *testing.T) {
330+
report := Inspect("wiki", conformingSolution(t))
331+
check := checkFor(t, report, CodeConsumedWithoutBinding)
332+
if check.Status != StatusWarn {
333+
t.Fatalf("a consumed module with no binding was %s, want warn", check.Status)
334+
}
335+
if !strings.Contains(check.Message, "documents") {
336+
t.Fatalf("the warning does not name the module: %q", check.Message)
337+
}
338+
if !report.Conforms() {
339+
t.Fatal("a warning was treated as a failure")
340+
}
341+
}
342+
343+
// THE MISSING-PERMISSION CLASS IS CAUGHT, and it is core that catches it: a
344+
// role may grant another module's resource kind only when one of the
345+
// contract's bindings spells that scope out, or it is within the module's own
346+
// ceilings. Running the contract through core's loader is what surfaces it —
347+
// which is the whole reason this package reads every document with the reader
348+
// that owns it instead of parsing YAML itself.
349+
func TestARoleGrantingAnUnbackedScopeIsReported(t *testing.T) {
350+
dir := conformingSolution(t)
351+
write(t, filepath.Join(dir, "module.contract.codefly.yaml"), `schema: codefly/module-contract/v1
352+
principal: wiki
353+
owner_aware: true
354+
namespaces: []
355+
queues: []
356+
scope_ceilings:
357+
- resource_kind: wiki.page
358+
actions: [read, write]
359+
roles:
360+
- name: installer
361+
scopes:
362+
- resource_kind: wiki.page
363+
actions: [read, write]
364+
- resource_kind: documents
365+
actions: [read]
366+
bindings: []
367+
`)
368+
report := Inspect("wiki", dir)
369+
check := checkFor(t, report, CodeContractUnreadable)
370+
if check.Status != StatusFail {
371+
t.Fatalf("a role granting a scope no binding backs was %s, want fail", check.Status)
372+
}
373+
if !strings.Contains(check.Message, "documents:read") {
374+
t.Fatalf("the failure does not name the unbacked grant: %q", check.Message)
375+
}
376+
}
377+
378+
// A shell declared with no app behind it is the state conforming the fleet
379+
// created: the manifest was added, the page was never written. It warns rather
380+
// than fails — the solution is correctly shaped and nothing breaks until
381+
// someone runs the shell — but it is reported, because a declared service with
382+
// no source is invisible to every other reading.
383+
func TestAPageShellWithNoAppWarns(t *testing.T) {
384+
dir := conformingSolution(t)
385+
report := Inspect("wiki", dir)
386+
if checkFor(t, report, CodePageShellHasNoApp).Status != StatusWarn {
387+
t.Fatal("a shell with no app behind it was not reported")
388+
}
389+
if !report.Conforms() {
390+
t.Fatal("a warning was treated as a failure")
391+
}
392+
393+
// With an app beside it, the finding goes away.
394+
write(t, filepath.Join(dir, "services", PageService, "package.json"), `{"name":"web","private":true}`)
395+
if checkFor(t, Inspect("wiki", dir), CodePageShellHasNoApp).Status != StatusOK {
396+
t.Fatal("a shell with a real app was still reported")
397+
}
398+
}

0 commit comments

Comments
 (0)