Repository navigation
Expand file tree
/
Copy pathcontract.go
More file actions
425 lines (401 loc) · 19.8 KB
/
Copy pathcontract.go
File metadata and controls
425 lines (401 loc) · 19.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
package solution
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"sort"
"strings"
"github.com/codefly-dev/core/resources"
)
// ContractSchema is the version of the contract document this runtime
// publishes. A reader branches on it rather than on the presence of a field, so
// a later shape is a new version and never a stricter reading of these bytes.
//
// It is deliberately *not* `codefly/module-contract/v1`. That string belongs to
// the document the renderer reads — `module.contract.codefly.yaml`, YAML,
// strict-decoded, at the module directory the composition resolved, carrying
// slots rather than literals (codefly-dev/cli#855). This runtime's document is
// JSON, has a different shape, and answers a different question: what a process
// holds *itself* to, resolved for the one profile it runs under.
//
// Sharing the string was worse than a mismatch. Strict decoding refuses the
// unknown fields, and because the schema matched, the renderer would report this
// as a *malformed* `module-contract` rather than as a document meant for
// somebody else — a version skew that is not one, pointing at the wrong owner.
// Two shapes under one schema string is the one thing a reader branching on that
// string cannot survive.
const ContractSchema = "codefly/solution-runtime-contract/v1"
// RendererContractSchema and RendererContractFile name what the *renderer*
// reads, so the distinction above is greppable from here rather than folklore.
// Nothing in this package writes that file; a generator for it would be a
// separate deliverable in this repository, and the renderer stays a reader of
// one file.
const (
RendererContractSchema = "codefly/module-contract/v1"
RendererContractFile = "module.contract.codefly.yaml"
)
// ContractPath is where a running solution publishes its effective contract.
//
// Published, not announced: nothing is pushed anywhere, and answering here
// makes this solution present to nobody. It is the same document the build-time
// artifact carries (ContractArtifact), narrowed to the one profile this process
// actually runs under, so an operator can read what a process holds itself to
// without inferring it from the composition's inputs.
const ContractPath = "/.well-known/module-contract"
// ModuleContract is the contract a solution declares: the ceiling this runtime
// holds its own passthrough declaration to at boot, and the ceiling an
// operator reads off a running process. It is optional: the authority bounds
// every call by the binding it is issued through and what the person holds,
// and a solution that declares none publishes an empty contract.
//
// It is the author's declaration of the most authority this solution may ever
// ask for. It is not the renderer's input — that is RendererContractFile, whose
// shape resolves audiences from the composition per environment rather than
// keying ceilings by profile (codefly-dev/cli#855).
//
// It is declared, never derived from what the code happens to ask for. A
// ceiling computed from the declaration would be satisfied by construction: a
// method that asked for one more action would widen the ceiling that was
// supposed to refuse it, and a reviewer approving the contract would be
// approving whatever the next commit asks for. So the author declares the
// ceiling, the runtime refuses a declaration that exceeds it, and the two
// disagreeing is a boot failure naming the scope.
//
// What *is* derived is the binding set: the audiences are the `as` of the
// solution's consumed modules, which the passthrough declaration (Consumes)
// already names. Restating them here would be a second source for one fact.
type ModuleContract struct {
// Ceilings is the most authority this solution may ever ask a consumed
// module for, keyed by configuration profile and then by the audience it is
// asked of (the `as` of that module's api.consumes entry). Every audience
// the solution mints authority for needs a ceiling in the profile this
// process runs under, and every scope the declaration asks for must fall
// inside it.
//
// The profile key is why this is a map of maps rather than one table. A
// deployment and a developer machine do not grant the same authority, and a
// deployed environment reads its own profile — "staging", say — never the
// local one. That distinction is recent: a deployed environment used to
// render under the local profile (codefly-dev/core#687, closed in core
// v0.7.1), which is exactly the mistake this keying makes impossible. A
// contract that declares only "local" is refused in a deployment rather
// than read as if the deployment were somebody's laptop.
Ceilings map[string]map[string][]Scope
}
// Contract declares the contract this solution publishes. Chainable.
//
// Not to be confused with Manifest.Contract, which is the capability contract
// id the host's handshake reads. This one is about authority: which audiences
// this solution's passthrough asks for, and how much it may ever ask of each.
func (s *Server) Contract(contract ModuleContract) *Server {
// FROZEN here, at the declaration boundary.
//
// This stored the caller's value, which means the caller's maps and the
// caller's backing arrays. Resolution and every gateway built afterwards
// retain the ceiling's slices, so keeping an `actions := []string{"read"}`
// used in the declaration and writing "delete" into it after boot changed
// the ceiling every later request is checked against — AND the contract
// this process publishes. The ask-copying fix hardened one side of that
// comparison; this is the other side, and a ceiling a caller can still
// edit is not a ceiling.
//
// At Contract() rather than at resolution, because that is the moment the
// value stops being the caller's business: a copy taken later leaves a
// window, and there is no second place to remember to take one.
s.declaredContract = frozenContract(contract)
return s
}
// frozenContract is a deep copy of a declaration: the profile map, each
// audience map, each ceiling slice, and the Actions and ResourceIDs inside
// every Scope. Nothing in it aliases anything the caller still holds.
func frozenContract(contract ModuleContract) ModuleContract {
if contract.Ceilings == nil {
return contract
}
frozen := ModuleContract{Ceilings: make(map[string]map[string][]Scope, len(contract.Ceilings))}
for profile, ceilings := range contract.Ceilings {
audiences := make(map[string][]Scope, len(ceilings))
for audience, ceiling := range ceilings {
audiences[audience] = frozenScopes(ceiling)
}
frozen.Ceilings[profile] = audiences
}
return frozen
}
// frozenScopes is a deep copy of a scope slice, including the two string
// slices inside each Scope — which a plain append copies the headers of and
// nothing else.
func frozenScopes(scopes []Scope) []Scope {
if scopes == nil {
return nil
}
frozen := make([]Scope, len(scopes))
for i, scope := range scopes {
frozen[i] = Scope{
ResourceKind: scope.ResourceKind,
Actions: append([]string(nil), scope.Actions...),
ResourceIDs: append([]string(nil), scope.ResourceIDs...),
}
}
return frozen
}
// effectiveContract is the contract of the one profile this process runs under:
// what it publishes, and what its declaration was held to at boot.
type effectiveContract struct {
Schema string `json:"schema"`
Solution string `json:"solution"`
Profile string `json:"profile"`
Bindings []contractBinding `json:"bindings"`
}
// contractBinding is one audience this solution holds authority for, and the
// ceiling it holds it within.
type contractBinding struct {
Audience string `json:"audience"`
Ceiling []Scope `json:"ceiling"`
// Asked is what the declaration actually asks for inside that ceiling —
// the authority a reviewer is approving rather than the authority the
// ceiling would allow a later commit to ask for.
Asked []Scope `json:"asked"`
}
// resolveContract is the contract this process publishes, refused rather than
// guessed. Every refusal names the value that is missing or the scope that
// exceeds its ceiling, because the fix for each is a different edit in a
// different file: a missing profile is a contract change, a scope outside its
// ceiling is either a declaration change or a ceiling the reviewer has to widen
// deliberately.
func (s *Server) resolveContract() (effectiveContract, error) {
contract := effectiveContract{
Schema: ContractSchema,
Solution: s.manifest.ID,
Profile: s.cfg.profile,
}
// A solution that publishes no ceiling is bounded by the authority alone:
// each call is issued through one of its bindings, within that binding's
// ceiling and what the person holds.
if len(s.declaredContract.Ceilings) == 0 {
return contract, nil
}
bindings, err := checkContract(s.consumed, s.declaredContract, s.cfg.profile)
if err != nil {
return contract, err
}
contract.Bindings = bindings
return contract, nil
}
// checkContract is the whole of the contract's substantive rules, for one
// profile, and it returns the bindings that profile publishes.
//
// One function, called by the boot *and* by the artifact a renderer reads. They
// used to be separate, and the artifact checked only profile names and scope
// syntax: a declaration consuming `things` could publish an artifact whose only
// ceiling was `ghost`, and the artifact is the document authority is derived
// from. A rule enforced on the running process and not on the published
// document is a rule that governs the half nobody reads.
func checkContract(modules []ConsumedModule, contract ModuleContract, profile string) ([]contractBinding, error) {
ceilings, declared := contract.Ceilings[profile]
if !declared && mintsAuthority(modules) {
return nil, fmt.Errorf("the published contract declares no %q profile (it declares: %s): a deployed environment reads its own profile and never the local one, so declare this one — or set %s if this process runs under another",
profile, declaredProfiles(contract), ContractProfileEnvironmentVariable)
}
var bindings []contractBinding
for _, module := range modules {
ceiling, declared := ceilings[module.As]
if !declared {
return nil, fmt.Errorf("the published contract declares no scope ceiling for %q in the %q profile: every consumed module this solution mints authority for needs one, and this runtime refuses an ask it cannot check against a declared ceiling",
module.As, profile)
}
if err := checkScopes(ceiling); err != nil {
return nil, fmt.Errorf("the scope ceiling for %q in the %q profile is unusable: %w", module.As, profile, err)
}
asked := askedScopes(module)
for _, scope := range asked {
if err := withinCeiling(scope, ceiling); err != nil {
return nil, fmt.Errorf("this solution asks %q for authority outside the ceiling its contract publishes for the %q profile: %w",
module.As, profile, err)
}
}
bindings = append(bindings, contractBinding{Audience: module.As, Ceiling: ceiling, Asked: asked})
}
// A ceiling for an audience this solution does not consume is authority
// nobody can ask for, and the renderer would grant it: the solution's
// api.consumes is the binding set, so a name that is not in it is either a
// typo or a grant that outlives the consumption it was written for.
for audience := range ceilings {
if !slices.ContainsFunc(modules, func(m ConsumedModule) bool { return m.As == audience }) {
return nil, fmt.Errorf("the published contract declares a scope ceiling for %q in the %q profile, which this solution does not consume: it is a ceiling governing a call that cannot happen, and whoever wrote it believes otherwise",
audience, profile)
}
}
sort.Slice(bindings, func(i, j int) bool { return bindings[i].Audience < bindings[j].Audience })
return bindings, nil
}
// declaredProfiles lists the profiles a contract does declare, so a refusal
// naming the missing one also says which ones exist — the difference between
// "add staging" and "the profile name is not what you think it is".
func declaredProfiles(contract ModuleContract) string {
if len(contract.Ceilings) == 0 {
return "none"
}
names := make([]string, 0, len(contract.Ceilings))
for name := range contract.Ceilings {
names = append(names, name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
// mintsAuthority reports whether any consumed module has authority to cap: every
// consumed module asks for some. A solution that consumes none asks for
// nothing, and requiring a profile from it would be requiring a declaration
// about authority that does not exist.
func mintsAuthority(modules []ConsumedModule) bool {
return len(modules) > 0
}
// askedScopes is every scope this solution's declaration asks of one module:
// the module's own, plus each method's override. The union is what the ceiling
// is checked against, because a per-method scope replaces the module's rather
// than narrowing it, so the widest ask is not the module-level one.
func askedScopes(module ConsumedModule) []Scope {
asked := append([]Scope{}, module.Scopes...)
for _, method := range module.Methods {
asked = append(asked, method.Scopes...)
}
return asked
}
// withinCeiling reports whether one asked scope falls inside a ceiling: some
// one entry of the ceiling must cover its resource kind, every action it asks
// for, and — when that entry names resource ids — every id it names.
//
// Every entry of that kind is examined before anything is refused. It used to
// return the first matching kind's verdict, which made the answer depend on the
// order the ceiling was written in: with `things/read/{a}` and
// `things/read/{b}` declared in that order, an ask for `b` was refused against
// the first entry and the second was never read. A ceiling is a set, and a
// reviewer who writes one does not also choose a traversal order.
//
// Coverage has to come from a single entry rather than from the union of
// several. Two entries that each cover half an ask describe two grants, and
// treating their union as one would let a ceiling of "read on a" plus "list on
// b" authorize "read and list on a and b" — authority nobody declared.
//
// An entry with no resource ids covers the whole resource kind, which is how
// Scope itself reads an empty ResourceIDs. An ask with no ids against entries
// that all name some is therefore refused: "every document" is not inside
// "these two documents", and silently reading it as the narrower ask would mint
// authority the declaration did not write.
func withinCeiling(asked Scope, ceiling []Scope) error {
var reasons []string
kindNamed := false
for _, allowed := range ceiling {
if allowed.ResourceKind != asked.ResourceKind {
continue
}
kindNamed = true
if reason := coveredBy(asked, allowed); reason != "" {
reasons = append(reasons, reason)
continue
}
return nil
}
if !kindNamed {
return fmt.Errorf("the ceiling names no resource kind %q at all", asked.ResourceKind)
}
// Every entry of that kind was read and none covers the ask. The reasons
// are reported together, because "which of the three entries did you mean"
// is the first question a reader has.
return fmt.Errorf("no single ceiling entry for %q covers it: %s", asked.ResourceKind, strings.Join(reasons, "; "))
}
// coveredBy is why one ceiling entry does not cover an ask, or "" when it does.
func coveredBy(asked, allowed Scope) string {
for _, action := range asked.Actions {
if !slices.Contains(allowed.Actions, action) {
return fmt.Sprintf("the entry allowing [%s] does not allow %q", strings.Join(allowed.Actions, ", "), action)
}
}
if len(allowed.ResourceIDs) == 0 {
return ""
}
if len(asked.ResourceIDs) == 0 {
return fmt.Sprintf("the entry names only the resources [%s], and the ask covers the whole resource kind", strings.Join(allowed.ResourceIDs, ", "))
}
for _, id := range asked.ResourceIDs {
if !slices.Contains(allowed.ResourceIDs, id) {
return fmt.Sprintf("the entry naming [%s] does not name %q", strings.Join(allowed.ResourceIDs, ", "), id)
}
}
return ""
}
func (s *Server) handleContract(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.contract)
}
// ContractArtifact renders this runtime's contract document at build time: the
// binding set derived from this solution's api.consumes declaration, and the
// scope ceiling it may ever ask of each, per profile — the bytes to write beside
// the interface artifact (InterfaceArtifact).
//
// It is *not* the document the renderer derives authority from. The renderer
// reads RendererContractFile under RendererContractSchema, a YAML shape with
// `{from: <group>/<key>}` slots that a composition resolves per environment
// (codefly-dev/cli#855); these bytes are this runtime's own document, under this
// runtime's own schema string, and the renderer does not read them. What they
// are good for is review and diffing a build: the ceiling an author declared,
// checked by exactly the rule the boot applies.
//
// It takes the declaration rather than a running server, because a render
// happens where no solution is running, and it resolves no profile for the same
// reason: which profile applies is the renderer's to decide, and a build-time
// document that had already chosen one would be a document that is true in one
// environment.
func ContractArtifact(id string, contract ModuleContract, modules ...ConsumedModule) ([]byte, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("a contract artifact names no solution: pass the manifest id")
}
document := struct {
Schema string `json:"schema"`
Solution string `json:"solution"`
Bindings []artifactBinding `json:"bindings"`
Profiles map[string]map[string][]Scope `json:"profiles"`
}{Schema: ContractSchema, Solution: id, Profiles: map[string]map[string][]Scope{}}
for _, module := range modules {
document.Bindings = append(document.Bindings, artifactBinding{Audience: module.As})
}
sort.Slice(document.Bindings, func(i, j int) bool { return document.Bindings[i].Audience < document.Bindings[j].Audience })
for profile, ceilings := range contract.Ceilings {
if err := resources.ValidateConfigurationProfileName(profile); err != nil {
return nil, fmt.Errorf("the contract declares an unusable profile name: %w", err)
}
// Every rule the boot holds a running process to, held here too, per
// profile — so a document that would refuse to boot cannot be
// published at all.
if _, err := checkContract(modules, contract, profile); err != nil {
return nil, err
}
document.Profiles[profile] = ceilings
}
if mintsAuthority(modules) && len(document.Profiles) == 0 {
// Says what it checks. It used to demand "a deployed one beside local"
// while accepting a contract that declared only local, which is a
// refusal promising a stricter rule than the one it applies — and the
// reader who satisfies the message learns nothing, because they were
// already passing.
//
// Which profiles will be deployed is not knowable here: a build-time
// document is rendered once and read under whichever profile a
// composition runs. A deployed environment missing its profile is
// refused at boot, where the profile it ran under is a fact and the
// refusal can name it, rather than guessed at here — and a
// local-only contract is the correct contract for a solution that is
// only ever run locally.
return nil, fmt.Errorf("the contract declares no profile at all, and this solution mints authority for %d consumed module(s): declare the scope ceilings per profile — a deployed environment missing its own profile is then refused at boot, naming the profile it ran under",
len(modules))
}
return json.MarshalIndent(document, "", " ")
}
// artifactBinding is one audience the solution holds a binding for.
type artifactBinding struct {
Audience string `json:"audience"`
}
// localProfile is the profile a local run resolves to: the local environment's
// own name, which is what Core uses for an environment that declares no profile
// of its own.
const localProfile = "local"