| 1 | package caps |
| 2 | |
| 3 | // Grants are the OPERATOR half of the capability model (spec §2 "Grants", |
| 4 | // §7): a manifest DECLARES `ports` and `host_role`, and nothing materializes |
| 5 | // until an operator has granted it ON THIS BOX. The grant lives in the |
| 6 | // registry's `caps` column — a place no tenant can write — and this file is |
| 7 | // the whole vocabulary of what that column may say. |
| 8 | // |
| 9 | // The token set is closed and tiny: |
| 10 | // |
| 11 | // host-role the app's processes may use the box's instance role |
| 12 | // port:<n> the app may bind privileged port n (1..1023, never 22/80/443) |
| 13 | // doc-engine the app is a document-conversion orchestrator (Chromium, |
| 14 | // LibreOffice, Tesseract-class): JIT and resource headroom, |
| 15 | // in exchange for a private-network egress fence |
| 16 | // |
| 17 | // Per route, per box, deliberately (decision 4): `:25` can be held by exactly |
| 18 | // one process on one machine, and the IMDS gate is that box's firewall. Two |
| 19 | // boxes running the same app is two grants. |
| 20 | |
| 21 | import ( |
| 22 | "fmt" |
| 23 | "sort" |
| 24 | "strconv" |
| 25 | "strings" |
| 26 | ) |
| 27 | |
| 28 | // HostRoleToken is the grant that hands an app the box's cloud instance |
| 29 | // role — the one real transfer of authority in the model, and the reason |
| 30 | // grants are typed by an operator on the box rather than declared by an app |
| 31 | // in a manifest (spec §7). |
| 32 | const HostRoleToken = "host-role" |
| 33 | |
| 34 | // DocEngineToken is the grant that says this route runs a document-conversion |
| 35 | // orchestrator — Gotenberg, an OCR service, anything driving Chromium or |
| 36 | // LibreOffice over untrusted documents. It is a TRADE, not a gift: the app |
| 37 | // gets the resource headroom those engines cannot run without, and in the |
| 38 | // same drop-in the platform fences its egress away from every private network |
| 39 | // the engine could otherwise be SSRF'd into. RenderService emits both halves |
| 40 | // together or neither, so an operator cannot grant the headroom and forget |
| 41 | // the fence. |
| 42 | // |
| 43 | // Not spelled "engine": `carlos store scan` already uses that word for the |
| 44 | // antivirus engine throughout its operator-facing prose, and two meanings for |
| 45 | // one token would make every caps column ambiguous to read. |
| 46 | const DocEngineToken = "doc-engine" |
| 47 | |
| 48 | // portTokenPrefix is the only other token shape. Kept as a constant so the |
| 49 | // parser and the formatter can never drift apart by a character. |
| 50 | const portTokenPrefix = "port:" |
| 51 | |
| 52 | // PortToken is the canonical grant token for a privileged port ("port:25"). |
| 53 | // It is the ONLY producer of that string: ValidGrantToken refuses any port |
| 54 | // token this function would not have written, so "port:025" and "port:+25" |
| 55 | // never reach the column and two spellings of one grant can never coexist. |
| 56 | func PortToken(p int) string { return portTokenPrefix + strconv.Itoa(p) } |
| 57 | |
| 58 | // Grants is a parsed `caps` column: which privileged ports this route may |
| 59 | // bind on this box, whether it may use the box's instance role, and whether |
| 60 | // it may run as a document-conversion engine. |
| 61 | type Grants struct { |
| 62 | Ports map[int]bool |
| 63 | HostRole bool |
| 64 | DocEngine bool |
| 65 | } |
| 66 | |
| 67 | // Has reports whether g holds tok. The token is not validated — an |
| 68 | // unparseable token is simply not held, which is the honest answer for |
| 69 | // "is this granted?". |
| 70 | func (g Grants) Has(tok string) bool { |
| 71 | if tok == HostRoleToken { |
| 72 | return g.HostRole |
| 73 | } |
| 74 | if tok == DocEngineToken { |
| 75 | return g.DocEngine |
| 76 | } |
| 77 | if p, ok := parsePortToken(tok); ok { |
| 78 | return g.Ports[p] |
| 79 | } |
| 80 | return false |
| 81 | } |
| 82 | |
| 83 | // String is the canonical column value: every held grant as a token, sorted |
| 84 | // lexicographically ("host-role,port:25"), so re-granting a set already |
| 85 | // granted writes the same bytes and a diff of two registries is meaningful. |
| 86 | // The zero Grants is "". |
| 87 | // |
| 88 | // The sort is over the token STRINGS, not the port numbers, so ports order |
| 89 | // as "port:25", "port:465", "port:5". That is deliberate: the only property |
| 90 | // this value needs is that one set has exactly one spelling. |
| 91 | func (g Grants) String() string { |
| 92 | var toks []string |
| 93 | if g.DocEngine { |
| 94 | toks = append(toks, DocEngineToken) |
| 95 | } |
| 96 | if g.HostRole { |
| 97 | toks = append(toks, HostRoleToken) |
| 98 | } |
| 99 | for p, ok := range g.Ports { |
| 100 | if ok { |
| 101 | toks = append(toks, PortToken(p)) |
| 102 | } |
| 103 | } |
| 104 | sort.Strings(toks) |
| 105 | return strings.Join(toks, ",") |
| 106 | } |
| 107 | |
| 108 | // ParseGrants reads the registry's `caps` column. Unknown or malformed |
| 109 | // tokens are an ERROR, not a silent skip: this column is written ONLY by |
| 110 | // registry.SetCaps behind `carlos route -grant/-revoke`, so anything else in |
| 111 | // it means the row was edited by hand, and the honest answer is to refuse to |
| 112 | // materialize the route rather than guess which subset was meant. A |
| 113 | // duplicate token is refused on the same grounds — String never writes one. |
| 114 | // |
| 115 | // "" is the zero Grants with no error: nothing granted, which is every row |
| 116 | // on every box today and the behaviour byte for byte. |
| 117 | func ParseGrants(col string) (Grants, error) { |
| 118 | var g Grants |
| 119 | if col == "" { |
| 120 | return g, nil |
| 121 | } |
| 122 | for _, tok := range strings.Split(col, ",") { |
| 123 | if err := ValidGrantToken(tok); err != nil { |
| 124 | return Grants{}, fmt.Errorf("caps column %q: %w", col, err) |
| 125 | } |
| 126 | if g.Has(tok) { |
| 127 | return Grants{}, fmt.Errorf("caps column %q: %q appears more than once — this column is written only by `carlos route --grant`, so a duplicate means a hand edit", col, tok) |
| 128 | } |
| 129 | if tok == HostRoleToken { |
| 130 | g.HostRole = true |
| 131 | continue |
| 132 | } |
| 133 | if tok == DocEngineToken { |
| 134 | g.DocEngine = true |
| 135 | continue |
| 136 | } |
| 137 | p, _ := parsePortToken(tok) // ValidGrantToken proved the shape |
| 138 | if g.Ports == nil { |
| 139 | g.Ports = map[int]bool{} |
| 140 | } |
| 141 | g.Ports[p] = true |
| 142 | } |
| 143 | return g, nil |
| 144 | } |
| 145 | |
| 146 | // ValidGrantToken is the rail an operator meets at `carlos route -grant`, |
| 147 | // and the same rail ParseGrants applies to the stored column. It refuses: |
| 148 | // |
| 149 | // - an unknown token shape (only "host-role", "doc-engine" and "port:<n>" |
| 150 | // exist); |
| 151 | // - a non-canonical port spelling ("port:025", "port:+25", "port: 25") — |
| 152 | // one grant has exactly one spelling; |
| 153 | // - a port outside 1..1023, because a high port needs no help from the |
| 154 | // platform and letting the vocabulary cover them would blur what the |
| 155 | // capability is for (spec §7); |
| 156 | // - 22, 80 and 443, which are reserved ABSOLUTELY and are not grantable: |
| 157 | // an operator typo must not be able to hand a tenant the edge's ports or |
| 158 | // the box's SSH. |
| 159 | // |
| 160 | // A token can therefore never contain a comma or a newline, which is what |
| 161 | // keeps the comma-separated column unambiguous. |
| 162 | func ValidGrantToken(tok string) error { |
| 163 | if tok == "" { |
| 164 | return fmt.Errorf("grant token is empty — want %q, %q or %q", HostRoleToken, DocEngineToken, PortToken(25)) |
| 165 | } |
| 166 | if tok == HostRoleToken || tok == DocEngineToken { |
| 167 | return nil |
| 168 | } |
| 169 | if !strings.HasPrefix(tok, portTokenPrefix) { |
| 170 | return fmt.Errorf("unknown grant token %q — the vocabulary is %q, %q and %q", tok, HostRoleToken, DocEngineToken, portTokenPrefix+"<n>") |
| 171 | } |
| 172 | p, ok := parsePortToken(tok) |
| 173 | if !ok { |
| 174 | return fmt.Errorf("grant token %q: %s must be a decimal port number, canonically spelled (e.g. %q)", tok, portTokenPrefix+"<n>", PortToken(25)) |
| 175 | } |
| 176 | if p < 1 || p > 1023 { |
| 177 | return fmt.Errorf("grant token %q: port %d is outside 1..1023 — only privileged ports are a capability; a high port needs no help from the platform", tok, p) |
| 178 | } |
| 179 | if reservedPort[p] { |
| 180 | return fmt.Errorf("grant token %q: port %d is reserved absolutely and is not grantable — it belongs to the edge or to SSH", tok, p) |
| 181 | } |
| 182 | return nil |
| 183 | } |
| 184 | |
| 185 | // parsePortToken returns the port a "port:<n>" token names, and whether the |
| 186 | // token is that shape AND spelled the way PortToken would have spelled it. |
| 187 | // The round-trip check is what refuses "port:025" and "port:+25": strconv |
| 188 | // accepts both, and accepting them would let one grant have two spellings in |
| 189 | // one column. |
| 190 | func parsePortToken(tok string) (int, bool) { |
| 191 | rest, ok := strings.CutPrefix(tok, portTokenPrefix) |
| 192 | if !ok { |
| 193 | return 0, false |
| 194 | } |
| 195 | p, err := strconv.Atoi(rest) |
| 196 | if err != nil || PortToken(p) != tok { |
| 197 | return 0, false |
| 198 | } |
| 199 | return p, true |
| 200 | } |
| 201 | |
| 202 | // Grant returns the canonical column value with tok added. Granting what is |
| 203 | // already granted is idempotent — same set, same bytes — so a re-run of an |
| 204 | // operator's command is a success, not a conflict. |
| 205 | func Grant(col, tok string) (string, error) { |
| 206 | if err := ValidGrantToken(tok); err != nil { |
| 207 | return "", err |
| 208 | } |
| 209 | g, err := ParseGrants(col) |
| 210 | if err != nil { |
| 211 | return "", err |
| 212 | } |
| 213 | if tok == HostRoleToken { |
| 214 | g.HostRole = true |
| 215 | } else if tok == DocEngineToken { |
| 216 | g.DocEngine = true |
| 217 | } else { |
| 218 | p, _ := parsePortToken(tok) |
| 219 | if g.Ports == nil { |
| 220 | g.Ports = map[int]bool{} |
| 221 | } |
| 222 | g.Ports[p] = true |
| 223 | } |
| 224 | return g.String(), nil |
| 225 | } |
| 226 | |
| 227 | // Revoke returns the canonical column value with tok removed. Revoking a |
| 228 | // grant that was never made is a quiet success: the caller's goal is "not |
| 229 | // granted", not "I removed something" (the same contract DeleteAccount |
| 230 | // documents). The token is still validated — an operator who typed a |
| 231 | // nonsense token deserves to hear so rather than a cheerful no-op. |
| 232 | func Revoke(col, tok string) (string, error) { |
| 233 | if err := ValidGrantToken(tok); err != nil { |
| 234 | return "", err |
| 235 | } |
| 236 | g, err := ParseGrants(col) |
| 237 | if err != nil { |
| 238 | return "", err |
| 239 | } |
| 240 | if tok == HostRoleToken { |
| 241 | g.HostRole = false |
| 242 | } else if tok == DocEngineToken { |
| 243 | g.DocEngine = false |
| 244 | } else if p, ok := parsePortToken(tok); ok { |
| 245 | delete(g.Ports, p) |
| 246 | } |
| 247 | return g.String(), nil |
| 248 | } |
| 249 | |