Name who a read-write access grants its path's group to, so modules write shared data through the group the operator gave it (hq ADR 0312, issue 537)

Each read-write access is composed with a grant: the module's containers that
mount its path and the account its tools run as (the operator's, or the module's
own under ADR 0259 §8); that runtime restarts on the access, so it holds a group
the engine put its account in. A read access grants nothing. Sent only to an
engine whose statement says GrantContract (4), since an older one refuses the
field. Pins the node-engine at the branch that reads it.
This commit is contained in:
2026-10-12 13:47:17 +02:00
parent 4b1dee6b79
commit 10d4712f82
9 changed files with 410 additions and 5 deletions
+72
View File
@@ -464,6 +464,35 @@ type Access struct {
Path string `json:"path"`
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
Mode string `json:"mode,omitempty"`
// Grant is who the path's own group is granted to, for a read-write access (novox/hq ADR 0312 §1): the
// identities the module's code runs as. The controller says who; the node-engine reads which group owns
// the path when it applies, because only the machine knows, and never changes the path (§2). Absent is a
// controller older than the grant, or an access that grants nothing — and a read access always is one (§3).
Grant *AccessGrant `json:"grant,omitempty"`
}
// AccessGrant is the identities a read-write access grants its path's group to (novox/hq ADR 0312 §1).
type AccessGrant struct {
// Containers are the module's containers that mount the path, by id: each is created with the group as a
// supplementary group of its process, and recreated when the group moves (§6).
Containers []string `json:"containers,omitempty"`
// Account is the account that runs the module's tools: put in the group as an account group, as ADR 0252
// §1 puts one, and taken out again as §3 takes back (§5). Empty for a module whose code runs nowhere as
// an account.
Account string `json:"account,omitempty"`
}
// UnmarshalJSON reads a grant as strictly as a resource is read: a field the engine does not know is a
// thing the controller believes it asked for, and is refused rather than ignored.
func (g *AccessGrant) UnmarshalJSON(raw []byte) error {
type plain AccessGrant
var p plain
if err := strictDecode(raw, &p); err != nil {
return fmt.Errorf("a grant: %w", err)
}
*g = AccessGrant(p)
return nil
}
func (a *Access) Identity() string { return a.ID }
@@ -482,6 +511,22 @@ func (a *Access) validate(where string, _ bool) []string {
problems = append(problems, fmt.Sprintf(
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
}
if a.Grant != nil && a.Mode != AccessReadWrite {
// A read access reads as any account may, and gains no group (novox/hq ADR 0312 §3).
problems = append(problems, where+": a read access grants nothing, and this one names a grant; "+
"only a read-write access grants its path's group (novox/hq ADR 0312 §3)")
}
if a.Grant != nil {
for _, id := range a.Grant.Containers {
if id == "" {
problems = append(problems, where+": a grant names a container with no id")
}
}
if a.Grant.Account != "" && strings.ContainsAny(a.Grant.Account, " :/\t\n") {
problems = append(problems, where+": a grant's account is an account's name, and "+
a.Grant.Account+" is not one")
}
}
return problems
}
@@ -1668,6 +1713,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
d.Resources = append(d.Resources, resource)
}
problems = append(problems, checkWhileStopped(d.Resources)...)
problems = append(problems, checkGrants(d.Resources)...)
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
problems = append(problems, checkReplaces(d.Resources)...)
if env.Adoption == nil {
@@ -1732,6 +1778,32 @@ func checkWhileStopped(resources []Resource) []string {
return problems
}
// checkGrants judges each access's grant against the whole declaration (novox/hq ADR 0312): a container it
// names is a container of this declaration, as a maintenance window's are. A grant to a container the machine
// is not told about would be a group given to nothing, said as granted.
func checkGrants(resources []Resource) []string {
containers := map[string]bool{}
for _, r := range resources {
if r.Kind() == TypeContainer {
containers[r.Identity()] = true
}
}
var problems []string
for _, r := range resources {
a, ok := r.(*Access)
if !ok || a.Grant == nil {
continue
}
for _, id := range a.Grant.Containers {
if id != "" && !containers[id] {
problems = append(problems, fmt.Sprintf("resource %q: its grant names %q, and this declaration "+
"has no container by that id", a.ID, id))
}
}
}
return problems
}
func strictDecode(raw []byte, into any) error {
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
// field the host does not know is a thing the control plane believes it asked for.