package objstore import ( "context" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/release" ) // EntitlementSchema versions store/entitlements/.json independently of // Schema: the per-app record and the per-account entitlement are written by // different doors, for different actors, and neither's shape should force a // version bump on the other. const EntitlementSchema = 1 // Entitlement is store/entitlements/.json — the account's half of // self-serve object storage (#381): an owner ASKS for storage and a // deployment operator DECIDES. Two blocks for two actors, the account-level // twin of Record's three-for-three. // // NOT UNDER accounts/. The obvious home was accounts//store.json, beside // account.json, and it is the wrong one: accounts//** is inside the // fleet READ ACL (internal/edge/storedoor.go, aclRead), so every box // credential of that account could read the applicant's note, the deciding // operator's address, and the decline reason. A decline reason is an // operator's commentary ABOUT a tenant; it must not be readable BY that // tenant's boxes. A top-level prefix is in none of aclRead's three subtrees // and none of aclListScope's cases, so it is refused by default rather than by // a rule someone has to remember to keep — pinned by // TestEntitlementsAreNotReadableThroughTheEdgeDoor in internal/edge. // // STATE IS DERIVED, NEVER STORED. No object means never applied; Applied with // no Decision is pending; a Decision decides it, and Approved says which way. // Record's null-or-present tri-state, for the same reason: a `state string` // beside these blocks can disagree with them, and then two readers disagree // about the account. // // The secret-free rule carries over from Record: nothing here is a credential, // and nothing here names one. type Entitlement struct { Schema int `json:"schema"` Account string `json:"account"` Applied *Applied `json:"applied,omitempty"` Decision *Decision `json:"decision,omitempty"` } // Applied is the owner's half: the ask, and what it is for. type Applied struct { By string `json:"by"` At time.Time `json:"at"` Note string `json:"note,omitempty"` } // Decision is the operator's half. Reason is required on a decline and // meaningless on an approval — the door enforces that, not this struct. type Decision struct { Approved bool `json:"approved"` By string `json:"by"` At time.Time `json:"at"` Reason string `json:"reason,omitempty"` } // EntitlementKey is the bucket key for one account's entitlement. func EntitlementKey(acct string) string { return "store/entitlements/" + acct + ".json" } // Approved reports whether this account may provision stores without an // operator running grant for each one. A nil entitlement is not approved, // which is the fail-closed direction and the state every account starts in. func (e *Entitlement) Approved() bool { return e != nil && e.Decision != nil && e.Decision.Approved } // Pending reports an application waiting on an operator — applied, undecided. // This is the state the operator queue lists and the one a member is told they // are in. func (e *Entitlement) Pending() bool { return e != nil && e.Applied != nil && e.Decision == nil } // Declined reports a decision that said no. Distinct from Pending and from // never-applied because the member is owed a different sentence for each. func (e *Entitlement) Declined() bool { return e != nil && e.Decision != nil && !e.Decision.Approved } // GetEntitlement reads one account's entitlement. Absent is (nil, "", nil) — // Get's idiom (objstore.go), so a caller that knows one knows the other, and // "no entitlement" never arrives as an error a door has to special-case. func GetEntitlement(ctx context.Context, s release.Store, acct string) (*Entitlement, string, error) { if !release.ValidAccount(acct) { return nil, "", fmt.Errorf("objstore: invalid account %q", acct) } raw, etag, err := s.Get(ctx, EntitlementKey(acct)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", nil } return nil, "", err } var e Entitlement if err := json.Unmarshal(raw, &e); err != nil { return nil, "", fmt.Errorf("objstore: %s: %w", EntitlementKey(acct), err) } return &e, etag, nil } // PutEntitlement CAS-writes one account's entitlement. Schema and Account are // stamped here rather than trusted from the caller, exactly as Put stamps // Record's — the key already says which account this is, and a body that // disagreed with its own key would be the corruption every reader after it // inherits. func PutEntitlement(ctx context.Context, s release.Store, acct string, e *Entitlement, etag string) (string, error) { if !release.ValidAccount(acct) { return "", fmt.Errorf("objstore: invalid account %q", acct) } e.Schema = EntitlementSchema e.Account = acct body, err := json.MarshalIndent(e, "", " ") if err != nil { return "", err } return s.Put(ctx, EntitlementKey(acct), append(body, '\n'), etag) }