carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// EntitlementSchema versions store/entitlements/<sqid>.json independently of
15// Schema: the per-app record and the per-account entitlement are written by
16// different doors, for different actors, and neither's shape should force a
17// version bump on the other.
18const EntitlementSchema = 1
19
20// Entitlement is store/entitlements/<sqid>.json — the account's half of
21// self-serve object storage (#381): an owner ASKS for storage and a
22// deployment operator DECIDES. Two blocks for two actors, the account-level
23// twin of Record's three-for-three.
24//
25// NOT UNDER accounts/. The obvious home was accounts/<sqid>/store.json, beside
26// account.json, and it is the wrong one: accounts/<sqid>/** is inside the
27// fleet READ ACL (internal/edge/storedoor.go, aclRead), so every box
28// credential of that account could read the applicant's note, the deciding
29// operator's address, and the decline reason. A decline reason is an
30// operator's commentary ABOUT a tenant; it must not be readable BY that
31// tenant's boxes. A top-level prefix is in none of aclRead's three subtrees
32// and none of aclListScope's cases, so it is refused by default rather than by
33// a rule someone has to remember to keep — pinned by
34// TestEntitlementsAreNotReadableThroughTheEdgeDoor in internal/edge.
35//
36// STATE IS DERIVED, NEVER STORED. No object means never applied; Applied with
37// no Decision is pending; a Decision decides it, and Approved says which way.
38// Record's null-or-present tri-state, for the same reason: a `state string`
39// beside these blocks can disagree with them, and then two readers disagree
40// about the account.
41//
42// The secret-free rule carries over from Record: nothing here is a credential,
43// and nothing here names one.
44type Entitlement struct {
45 Schema int `json:"schema"`
46 Account string `json:"account"`
47 Applied *Applied `json:"applied,omitempty"`
48 Decision *Decision `json:"decision,omitempty"`
49}
50
51// Applied is the owner's half: the ask, and what it is for.
52type Applied struct {
53 By string `json:"by"`
54 At time.Time `json:"at"`
55 Note string `json:"note,omitempty"`
56}
57
58// Decision is the operator's half. Reason is required on a decline and
59// meaningless on an approval — the door enforces that, not this struct.
60type Decision struct {
61 Approved bool `json:"approved"`
62 By string `json:"by"`
63 At time.Time `json:"at"`
64 Reason string `json:"reason,omitempty"`
65}
66
67// EntitlementKey is the bucket key for one account's entitlement.
68func EntitlementKey(acct string) string { return "store/entitlements/" + acct + ".json" }
69
70// Approved reports whether this account may provision stores without an
71// operator running grant for each one. A nil entitlement is not approved,
72// which is the fail-closed direction and the state every account starts in.
73func (e *Entitlement) Approved() bool {
74 return e != nil && e.Decision != nil && e.Decision.Approved
75}
76
77// Pending reports an application waiting on an operator — applied, undecided.
78// This is the state the operator queue lists and the one a member is told they
79// are in.
80func (e *Entitlement) Pending() bool {
81 return e != nil && e.Applied != nil && e.Decision == nil
82}
83
84// Declined reports a decision that said no. Distinct from Pending and from
85// never-applied because the member is owed a different sentence for each.
86func (e *Entitlement) Declined() bool {
87 return e != nil && e.Decision != nil && !e.Decision.Approved
88}
89
90// GetEntitlement reads one account's entitlement. Absent is (nil, "", nil) —
91// Get's idiom (objstore.go), so a caller that knows one knows the other, and
92// "no entitlement" never arrives as an error a door has to special-case.
93func GetEntitlement(ctx context.Context, s release.Store, acct string) (*Entitlement, string, error) {
94 if !release.ValidAccount(acct) {
95 return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
96 }
97 raw, etag, err := s.Get(ctx, EntitlementKey(acct))
98 if err != nil {
99 if errors.Is(err, os.ErrNotExist) {
100 return nil, "", nil
101 }
102 return nil, "", err
103 }
104 var e Entitlement
105 if err := json.Unmarshal(raw, &e); err != nil {
106 return nil, "", fmt.Errorf("objstore: %s: %w", EntitlementKey(acct), err)
107 }
108 return &e, etag, nil
109}
110
111// PutEntitlement CAS-writes one account's entitlement. Schema and Account are
112// stamped here rather than trusted from the caller, exactly as Put stamps
113// Record's — the key already says which account this is, and a body that
114// disagreed with its own key would be the corruption every reader after it
115// inherits.
116func PutEntitlement(ctx context.Context, s release.Store, acct string, e *Entitlement, etag string) (string, error) {
117 if !release.ValidAccount(acct) {
118 return "", fmt.Errorf("objstore: invalid account %q", acct)
119 }
120 e.Schema = EntitlementSchema
121 e.Account = acct
122 body, err := json.MarshalIndent(e, "", " ")
123 if err != nil {
124 return "", err
125 }
126 return s.Put(ctx, EntitlementKey(acct), append(body, '\n'), etag)
127}
128