| 1 | // Package objstore is the object-storage primitive's control plane (issue |
| 2 | // #55, spec 2026-08-10): the per-app store record, the Provisioner seam the |
| 3 | // console drives, and the idempotent walks that provision, rotate, and tear |
| 4 | // down a store. It never touches the data plane — apps talk to their bucket |
| 5 | // directly with the delivered credentials. |
| 6 | package objstore |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "os" |
| 14 | "regexp" |
| 15 | "sort" |
| 16 | "strings" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/appconfig" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | const Schema = 1 |
| 24 | |
| 25 | // DefaultEnvPrefix names the delivered env set when a declaration doesn't |
| 26 | // choose its own (§7 of the spec). |
| 27 | const DefaultEnvPrefix = "CARLOS_STORE" |
| 28 | |
| 29 | type Declared struct { |
| 30 | EnvPrefix string `json:"env_prefix"` |
| 31 | At time.Time `json:"at"` |
| 32 | By string `json:"by"` |
| 33 | // Seed is the seed id that created this store (seeded-instances spec §4 |
| 34 | // Storage, codex R5-1, R6-1): only a retry of that seed may use it, and |
| 35 | // only that seed's abort may tear it down. Empty for every other store. |
| 36 | Seed string `json:"seed,omitempty"` |
| 37 | } |
| 38 | |
| 39 | // Grant sources (#381). Source records WHY a grant exists, mirroring |
| 40 | // mail.GrantBlock.Source's vocabulary and its reason: an authority that can be |
| 41 | // recomputed later must say what it rested on, or recomputing it silently |
| 42 | // discards a deliberate decision. |
| 43 | // |
| 44 | // Not omitempty, matching mail's. A record written before this field existed |
| 45 | // decodes "" — which reads correctly as "granted by the operator path, before |
| 46 | // we recorded why" — and an empty string in a fresh write is a bug worth |
| 47 | // seeing rather than a field worth hiding. |
| 48 | const ( |
| 49 | // GrantSourceOperator: a deployment operator ran `carlos store grant` for |
| 50 | // this one app. The original and still the override path. |
| 51 | GrantSourceOperator = "operator" |
| 52 | // GrantSourceAccountApproval: the app's ACCOUNT is approved for object |
| 53 | // storage, so the member's own declare provisioned it. By names the |
| 54 | // operator who approved the account — a real person who really did |
| 55 | // authorise this spend, one level up. |
| 56 | GrantSourceAccountApproval = "account-approval" |
| 57 | ) |
| 58 | |
| 59 | type GrantBlock struct { |
| 60 | Backend string `json:"backend"` |
| 61 | Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish) |
| 62 | Source string `json:"source"` |
| 63 | At time.Time `json:"at"` |
| 64 | By string `json:"by"` |
| 65 | } |
| 66 | |
| 67 | type Provisioned struct { |
| 68 | Bucket string `json:"bucket"` |
| 69 | Region string `json:"region"` |
| 70 | Endpoint string `json:"endpoint,omitempty"` |
| 71 | IAMUser string `json:"iam_user"` |
| 72 | UserARN string `json:"user_arn"` |
| 73 | KeyID string `json:"key_id"` |
| 74 | PrevKeyID string `json:"prev_key_id,omitempty"` |
| 75 | At time.Time `json:"at"` |
| 76 | } |
| 77 | |
| 78 | // ScanRequested is the MEMBER's half of scanning: they asked for it, and they |
| 79 | // attested what kind of objects the store holds. |
| 80 | // |
| 81 | // Encrypted means CLIENT-SIDE encrypted — the objects reach the store as |
| 82 | // ciphertext, so a scanner cannot read them, every object would scan clean |
| 83 | // forever, and no coverage is claimed (spec §2.2). Server-side encryption |
| 84 | // (SSE-S3, SSE-KMS, on by default for every AWS bucket) is NOT this: it is |
| 85 | // transparent to a credentialled reader and scans perfectly well. The platform |
| 86 | // cannot tell the two apart by looking, which is why this is an attestation |
| 87 | // and not a detection. |
| 88 | type ScanRequested struct { |
| 89 | At time.Time `json:"at"` |
| 90 | By string `json:"by"` |
| 91 | Encrypted bool `json:"encrypted"` |
| 92 | } |
| 93 | |
| 94 | // ScanEnabled is the OPERATOR's half. Enabling calls out to the deployment's |
| 95 | // own cloud account and, on a metered engine, starts a bill — the same test |
| 96 | // that puts the store grant behind CARLOS_STORE_OPERATORS. |
| 97 | type ScanEnabled struct { |
| 98 | Engine string `json:"engine"` // "clamav" | "guardduty" |
| 99 | At time.Time `json:"at"` |
| 100 | By string `json:"by"` |
| 101 | } |
| 102 | |
| 103 | // ScanBlock is the scanning half of the record — the same three-author shape |
| 104 | // the store itself has. |
| 105 | // |
| 106 | // It holds NO sweep rollup. Counts live in the scan.json sidecar, whose single |
| 107 | // writer is the sweep, so a walk writing once per page over minutes never |
| 108 | // contends with the doors that write this object. |
| 109 | // |
| 110 | // Enforce holds the DenyTag ITSELF rather than a bool. Grant takes a |
| 111 | // Provisioner and has no Scanner to ask for the tag, so the record must carry |
| 112 | // everything the policy writer needs — which is also what lets `scan enforce |
| 113 | // off` clear a deny on a deployment with no engine built. A bool here would |
| 114 | // mean unsetting CARLOS_STORE_SCANNER left every enforcing store unrepairable. |
| 115 | type ScanBlock struct { |
| 116 | Requested *ScanRequested `json:"requested,omitempty"` |
| 117 | Enabled *ScanEnabled `json:"enabled,omitempty"` |
| 118 | Enforce *DenyTag `json:"enforce,omitempty"` |
| 119 | WebhookKeyID string `json:"webhook_key_id,omitempty"` |
| 120 | WebhookGen int `json:"webhook_gen,omitempty"` |
| 121 | WebhookConfigHash string `json:"webhook_config_hash,omitempty"` |
| 122 | } |
| 123 | |
| 124 | // Record is apps/<acct>/<app>/store.json — three blocks for three actors |
| 125 | // (member declares, operator grants, console provisions). The secret key is |
| 126 | // never here; it exists only sealed inside config.json. |
| 127 | // |
| 128 | // PARSER LIST: this package is not the only reader of this shape. |
| 129 | // release.PurgeApp parses `grant` and `provisioned` out of these bytes with |
| 130 | // its own local struct — release cannot import objstore, because objstore |
| 131 | // imports release — and REFUSES the box-side auto-purge when either block is |
| 132 | // present. Rename or restructure either key here and that fleet-wide guard |
| 133 | // silently stops seeing backend estate; the mirror test in walk_test.go |
| 134 | // (TestPurgeGuardSeesOurRecord) exists to fail loudly if that ever happens. |
| 135 | // Additive changes are safe; these two names are not free to move. |
| 136 | // |
| 137 | // `scanning` is such an additive change: release's local struct ignores keys |
| 138 | // it has never heard of, which TestPurgeGuardIgnoresScanningBlock asserts |
| 139 | // directly. That freedom does NOT extend to `grant` and `provisioned`. |
| 140 | type Record struct { |
| 141 | Schema int `json:"schema"` |
| 142 | App string `json:"app"` |
| 143 | // Environment names the config bundle this store belongs to and delivers |
| 144 | // into. "" is the app's own store — the only shape that existed before |
| 145 | // 2026-09-19 — and is omitted, so every record written before this field |
| 146 | // existed parses as the app store it is. |
| 147 | // |
| 148 | // Stamped by Put from the key it is written to and never trusted from a |
| 149 | // caller, exactly as App is: a record that disagrees with its own key is |
| 150 | // refused rather than guessed at (GetIn). |
| 151 | Environment string `json:"environment,omitempty"` |
| 152 | Declared *Declared `json:"declared,omitempty"` |
| 153 | Grant *GrantBlock `json:"grant,omitempty"` |
| 154 | Provisioned *Provisioned `json:"provisioned,omitempty"` |
| 155 | Scanning *ScanBlock `json:"scanning,omitempty"` |
| 156 | } |
| 157 | |
| 158 | // Key is the app's own store — KeyIn(acct, app, "") by another name, kept |
| 159 | // because it is what every caller that has no environment in hand asks for. |
| 160 | func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" } |
| 161 | |
| 162 | // StoresPrefix lists an app's ENVIRONMENT stores. The app's own store is NOT |
| 163 | // under it: store.json predates this collection and does not move (spec §4), |
| 164 | // so a reader after every store of an app reads Key AND lists this prefix — |
| 165 | // which is exactly what release.PurgeApp's guard does. |
| 166 | func StoresPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "stores/" } |
| 167 | |
| 168 | // KeyIn is the record key for one store of an app: the app's own when env is |
| 169 | // "", else the store belonging to that named config environment. A sibling |
| 170 | // collection, not a key nested under an instance — nothing in the bucket |
| 171 | // nests under an instance, and apps/<acct>/<app>/sidecars/<name>.json is the |
| 172 | // shape this follows. |
| 173 | func KeyIn(acct, app, env string) string { |
| 174 | if env == "" { |
| 175 | return Key(acct, app) |
| 176 | } |
| 177 | return StoresPrefix(acct, app) + env + ".json" |
| 178 | } |
| 179 | |
| 180 | // StoreEnvironments names every ENVIRONMENT this app has a store for, sorted, |
| 181 | // from a prefix listing — the app's own store is not among them. An app with |
| 182 | // none lists empty, not an error, the same convention as appconfig's |
| 183 | // ListEnvironments and for the same reason: "none" is a state a status page |
| 184 | // has to print either way. |
| 185 | // |
| 186 | // A key that is not <env>.json is skipped rather than guessed at: this |
| 187 | // prefix is ours alone, so anything else under it is somebody's hand edit, |
| 188 | // and a malformed name must not become a bucket coordinate. |
| 189 | func StoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) { |
| 190 | if _, enabled := s.(release.AppMutationRunner); enabled { |
| 191 | return StrictStoreEnvironments(ctx, s, acct, app) |
| 192 | } |
| 193 | if !release.ValidAccount(acct) { |
| 194 | return nil, fmt.Errorf("objstore: invalid account %q", acct) |
| 195 | } |
| 196 | if !release.ValidApp(app) { |
| 197 | return nil, fmt.Errorf("objstore: invalid app %q", app) |
| 198 | } |
| 199 | prefix := StoresPrefix(acct, app) |
| 200 | keys, err := s.List(ctx, prefix) |
| 201 | if err != nil { |
| 202 | return nil, err |
| 203 | } |
| 204 | out := make([]string, 0, len(keys)) |
| 205 | for _, key := range keys { |
| 206 | name, ok := strings.CutPrefix(key, prefix) |
| 207 | if !ok { |
| 208 | continue |
| 209 | } |
| 210 | name, ok = strings.CutSuffix(name, ".json") |
| 211 | if !ok || !appconfig.ValidEnvironmentName(name) { |
| 212 | continue |
| 213 | } |
| 214 | out = append(out, name) |
| 215 | } |
| 216 | sort.Strings(out) |
| 217 | return out, nil |
| 218 | } |
| 219 | |
| 220 | var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) |
| 221 | |
| 222 | func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } |
| 223 | |
| 224 | // EnvNames renders the delivered variable names for a prefix (§7). |
| 225 | func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string) { |
| 226 | return prefix + "_BUCKET", prefix + "_REGION", prefix + "_ENDPOINT", |
| 227 | prefix + "_ACCESS_KEY", prefix + "_SECRET_KEY" |
| 228 | } |
| 229 | |
| 230 | // Get reads the record. Absent is (nil, "", nil) — same idiom as |
| 231 | // appconfig.getIfExists, because "no store" is a state, not an error. |
| 232 | func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| 233 | return GetIn(ctx, s, acct, app, "") |
| 234 | } |
| 235 | |
| 236 | // GetIn reads one store of an app: the app's own when env is "", else the |
| 237 | // store belonging to that named config environment. Absent is (nil, "", nil), |
| 238 | // as in Get — "no store" is a state, not an error, for either shape. |
| 239 | // |
| 240 | // A record whose Environment disagrees with the key it was read from is |
| 241 | // REFUSED, not corrected: the field is stamped from the key by PutIn, so a |
| 242 | // disagreement means bucket data was hand-edited, and guessing which half is |
| 243 | // right would deliver one environment's credentials under another's name. |
| 244 | // Same rule provision.GetRecord applies to a record that lies about its own |
| 245 | // identity. |
| 246 | func GetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) { |
| 247 | _, strict := s.(release.AppMutationRunner) |
| 248 | return getIn(ctx, s, acct, app, env, strict) |
| 249 | } |
| 250 | |
| 251 | // StrictGetIn validates the record before it can supply teardown coordinates. |
| 252 | func StrictGetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) { |
| 253 | return getIn(ctx, s, acct, app, env, true) |
| 254 | } |
| 255 | |
| 256 | func getIn(ctx context.Context, s release.Store, acct, app, env string, strict bool) (*Record, string, error) { |
| 257 | if !release.ValidAccount(acct) { |
| 258 | return nil, "", fmt.Errorf("objstore: invalid account %q", acct) |
| 259 | } |
| 260 | if !release.ValidApp(app) { |
| 261 | return nil, "", fmt.Errorf("objstore: invalid app %q", app) |
| 262 | } |
| 263 | if env != "" && !appconfig.ValidEnvironmentName(env) { |
| 264 | return nil, "", fmt.Errorf("objstore: invalid environment %q", env) |
| 265 | } |
| 266 | key := KeyIn(acct, app, env) |
| 267 | raw, etag, err := s.Get(ctx, key) |
| 268 | if err != nil { |
| 269 | if errors.Is(err, os.ErrNotExist) { |
| 270 | return nil, "", nil |
| 271 | } |
| 272 | return nil, "", err |
| 273 | } |
| 274 | var rec Record |
| 275 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 276 | return nil, "", fmt.Errorf("objstore: %s: %w", key, err) |
| 277 | } |
| 278 | if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) { |
| 279 | return nil, "", fmt.Errorf("%w: %s", ErrStoreInventory, key) |
| 280 | } |
| 281 | if rec.Environment != env { |
| 282 | return nil, "", fmt.Errorf("objstore: %s names environment %q, not %q — refusing to read a record that disagrees with its own key", |
| 283 | key, rec.Environment, env) |
| 284 | } |
| 285 | return &rec, etag, nil |
| 286 | } |
| 287 | |
| 288 | // Put writes the record with the caller's CAS position; etag "" is |
| 289 | // create-only. Every writer is a read-modify-write, so a lost race is |
| 290 | // release.ErrCASConflict for the caller to surface, never to absorb. |
| 291 | func claimRawPut(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| 292 | return PutIn(ctx, s, acct, app, "", rec, etag) |
| 293 | } |
| 294 | |
| 295 | // PutIn writes one store of an app, stamping App and Environment from the key |
| 296 | // it is writing to — neither is trusted from the caller, so a record can never |
| 297 | // disagree with where it lives (GetIn refuses one that does). |
| 298 | func claimRawPutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (string, error) { |
| 299 | if env != "" && !appconfig.ValidEnvironmentName(env) { |
| 300 | return "", fmt.Errorf("objstore: invalid environment %q", env) |
| 301 | } |
| 302 | rec.Schema = Schema |
| 303 | rec.App = app |
| 304 | rec.Environment = env |
| 305 | body, err := json.MarshalIndent(rec, "", " ") |
| 306 | if err != nil { |
| 307 | return "", err |
| 308 | } |
| 309 | return s.Put(ctx, KeyIn(acct, app, env), append(body, '\n'), etag) |
| 310 | } |
| 311 | func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (claimResult0 string, claimResult1 error) { |
| 312 | claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.Put", func(claimContext context.Context) error { |
| 313 | claimResult0, claimResult1 = claimRawPut(claimContext, s, acct, app, rec, etag) |
| 314 | return claimResult1 |
| 315 | }) |
| 316 | return |
| 317 | } |
| 318 | func PutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (claimResult0 string, claimResult1 error) { |
| 319 | claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.PutIn", func(claimContext context.Context) error { |
| 320 | claimResult0, claimResult1 = claimRawPutIn(claimContext, s, acct, app, env, rec, etag) |
| 321 | return claimResult1 |
| 322 | }) |
| 323 | return |
| 324 | } |
| 325 | |
| 326 | // RevertIn puts one store record back the way it was before a write the |
| 327 | // caller made, which left the record at etag: prev's exact bytes, or no |
| 328 | // record at all when prev is nil. Both are conditional on etag: a record |
| 329 | // written since, by a grant or by another writer (the declare door takes |
| 330 | // no store lock), is not the caller's to undo, and RevertIn leaves it and |
| 331 | // answers release.ErrCASConflict. A record already gone counts as deleted. |
| 332 | func RevertIn(ctx context.Context, s release.Store, acct, app, env string, prev []byte, etag string) error { |
| 333 | return release.WithAppMutation(ctx, s, acct, app, "objstore.RevertIn", func(claimContext context.Context) error { |
| 334 | return claimRawRevertIn(claimContext, s, acct, app, env, prev, etag) |
| 335 | }) |
| 336 | } |
| 337 | |
| 338 | func claimRawRevertIn(ctx context.Context, s release.Store, acct, app, env string, prev []byte, etag string) error { |
| 339 | if env != "" && !appconfig.ValidEnvironmentName(env) { |
| 340 | return fmt.Errorf("objstore: invalid environment %q", env) |
| 341 | } |
| 342 | key := KeyIn(acct, app, env) |
| 343 | if prev == nil { |
| 344 | return release.DeleteIfMatch(ctx, s, key, etag) |
| 345 | } |
| 346 | _, err := s.Put(ctx, key, prev, etag) |
| 347 | return err |
| 348 | } |
| 349 | |
| 350 | // ErrStoreInventory means a store listing or body cannot establish its estate. |
| 351 | var ErrStoreInventory = errors.New("objstore: invalid store inventory") |
| 352 | |
| 353 | // StrictStoreEnvironments validates every listed key and body. A listed |
| 354 | // record that cannot be read is not evidence of an empty environment. |
| 355 | func StrictStoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) { |
| 356 | if !release.ValidAccount(acct) || !release.ValidApp(app) { |
| 357 | return nil, ErrStoreInventory |
| 358 | } |
| 359 | prefix := StoresPrefix(acct, app) |
| 360 | keys, err := s.List(ctx, prefix) |
| 361 | if err != nil { |
| 362 | return nil, err |
| 363 | } |
| 364 | out := make([]string, 0, len(keys)) |
| 365 | seen := map[string]bool{} |
| 366 | for _, key := range keys { |
| 367 | name, ok := strings.CutPrefix(key, prefix) |
| 368 | if !ok { |
| 369 | return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key) |
| 370 | } |
| 371 | name, ok = strings.CutSuffix(name, ".json") |
| 372 | if !ok || !appconfig.ValidEnvironmentName(name) || key != KeyIn(acct, app, name) { |
| 373 | return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key) |
| 374 | } |
| 375 | if seen[key] { |
| 376 | continue |
| 377 | } |
| 378 | seen[key] = true |
| 379 | rec, _, err := StrictGetIn(ctx, s, acct, app, name) |
| 380 | if err != nil { |
| 381 | return nil, err |
| 382 | } |
| 383 | if rec == nil { |
| 384 | return nil, fmt.Errorf("%w: listed record %s is absent", ErrStoreInventory, key) |
| 385 | } |
| 386 | out = append(out, name) |
| 387 | } |
| 388 | sort.Strings(out) |
| 389 | return out, nil |
| 390 | } |
| 391 | |