| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "io" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostops" |
| 12 | "github.com/carlosframework/platform/internal/objstore" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // Request-local per-call effect adapters for the enabled create path. Each |
| 17 | // backend or store call is its own durable effect under the request's |
| 18 | // registration; the effect's permit is what lets the call through the |
| 19 | // boundary handles beneath. Named private fields, every method explicit. |
| 20 | |
| 21 | type claimEffectContextKey struct{} |
| 22 | |
| 23 | type claimStoreEffects struct { |
| 24 | Runner hostops.EffectRunner |
| 25 | OperationID string |
| 26 | } |
| 27 | |
| 28 | func claimEffectID() (string, error) { |
| 29 | var b [16]byte |
| 30 | if _, err := rand.Read(b[:]); err != nil { |
| 31 | return "", err |
| 32 | } |
| 33 | return hex.EncodeToString(b[:]), nil |
| 34 | } |
| 35 | |
| 36 | func (e claimStoreEffects) run(ctx context.Context, resource, action string, inputs any, call func(context.Context) (string, error)) (string, error) { |
| 37 | id, err := claimEffectID() |
| 38 | if err != nil { |
| 39 | return "", err |
| 40 | } |
| 41 | body, err := json.Marshal(inputs) |
| 42 | if err != nil { |
| 43 | return "", err |
| 44 | } |
| 45 | return e.Runner.Run(ctx, hostops.Effect{ID: id, Resource: resource, Action: action, Inputs: map[string]string{"request": string(body)}}, call) |
| 46 | } |
| 47 | |
| 48 | type claimEffectProvisioner struct { |
| 49 | base objstore.Provisioner |
| 50 | effects claimStoreEffects |
| 51 | } |
| 52 | |
| 53 | var _ objstore.Provisioner = claimEffectProvisioner{} |
| 54 | |
| 55 | func (p claimEffectProvisioner) mutation(ctx context.Context, resource, action string, inputs any, call func(context.Context) error) error { |
| 56 | _, err := p.effects.run(ctx, resource, action, inputs, func(ctx context.Context) (string, error) { |
| 57 | if err := call(ctx); err != nil { |
| 58 | return "", err |
| 59 | } |
| 60 | return "joined", nil |
| 61 | }) |
| 62 | return err |
| 63 | } |
| 64 | func (p claimEffectProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { |
| 65 | return p.effects.run(ctx, "bucket:"+name, "ensure-bucket", name, func(ctx context.Context) (string, error) { return p.base.EnsureBucket(ctx, name) }) |
| 66 | } |
| 67 | func (p claimEffectProvisioner) EnsureUser(ctx context.Context, name, path, arn string, deny *objstore.DenyTag) (string, error) { |
| 68 | return p.effects.run(ctx, "iam-user:"+name, "ensure-user", []any{name, path, arn, deny}, func(ctx context.Context) (string, error) { |
| 69 | return p.base.EnsureUser(ctx, name, path, arn, deny) |
| 70 | }) |
| 71 | } |
| 72 | func (p claimEffectProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { |
| 73 | var id, secret string |
| 74 | _, err := p.effects.run(ctx, "iam-user:"+user, "mint-key", user, func(ctx context.Context) (string, error) { |
| 75 | var callErr error |
| 76 | id, secret, callErr = p.base.MintKey(ctx, user) |
| 77 | return id, callErr |
| 78 | }) |
| 79 | // Only the key ID is journaled; the secret travels through the sealed |
| 80 | // config walk, never through admission. |
| 81 | if err != nil { |
| 82 | return "", "", err |
| 83 | } |
| 84 | return id, secret, nil |
| 85 | } |
| 86 | func (p claimEffectProvisioner) DeleteKey(ctx context.Context, user, id string) error { |
| 87 | return p.mutation(ctx, "iam-user:"+user, "delete-key", []string{user, id}, func(ctx context.Context) error { return p.base.DeleteKey(ctx, user, id) }) |
| 88 | } |
| 89 | func (p claimEffectProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { |
| 90 | return p.mutation(ctx, "iam-user:"+user, "reap-keys", []any{user, keep}, func(ctx context.Context) error { return p.base.ReapKeys(ctx, user, keep) }) |
| 91 | } |
| 92 | func (p claimEffectProvisioner) EmptyBucket(ctx context.Context, name string) error { |
| 93 | return p.mutation(ctx, "bucket:"+name, "empty-bucket", name, func(ctx context.Context) error { return p.base.EmptyBucket(ctx, name) }) |
| 94 | } |
| 95 | func (p claimEffectProvisioner) DeleteBucket(ctx context.Context, name string) error { |
| 96 | return p.mutation(ctx, "bucket:"+name, "delete-bucket", name, func(ctx context.Context) error { return p.base.DeleteBucket(ctx, name) }) |
| 97 | } |
| 98 | func (p claimEffectProvisioner) DeleteUser(ctx context.Context, name string) error { |
| 99 | return p.mutation(ctx, "iam-user:"+name, "delete-user", name, func(ctx context.Context) error { return p.base.DeleteUser(ctx, name) }) |
| 100 | } |
| 101 | |
| 102 | // claimEffectStore wraps the data boundary for one request: every Put is an |
| 103 | // effect. Delete is refused (the walk never needs a bare delete). |
| 104 | type claimEffectStore struct { |
| 105 | data release.Store |
| 106 | effects claimStoreEffects |
| 107 | } |
| 108 | |
| 109 | var ( |
| 110 | _ release.Store = claimEffectStore{} |
| 111 | _ release.ConditionalDeleter = claimEffectStore{} |
| 112 | _ release.Opener = claimEffectStore{} |
| 113 | ) |
| 114 | |
| 115 | func (s claimEffectStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 116 | return s.data.Get(ctx, key) |
| 117 | } |
| 118 | func (s claimEffectStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 119 | return s.data.List(ctx, prefix) |
| 120 | } |
| 121 | func (s claimEffectStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 122 | return s.data.ListDirs(ctx, prefix) |
| 123 | } |
| 124 | func (s claimEffectStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| 125 | return release.OpenObject(ctx, s.data, key) |
| 126 | } |
| 127 | func (s claimEffectStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| 128 | // Hash and coordinates only: config payloads can carry sealed values. |
| 129 | sum := sha256.Sum256(body) |
| 130 | return s.effects.run(ctx, "control-object:"+key, "put", map[string]string{"key": key, "expected_etag": etag, "sha256": hex.EncodeToString(sum[:])}, func(ctx context.Context) (string, error) { |
| 131 | return s.data.Put(ctx, key, body, etag) |
| 132 | }) |
| 133 | } |
| 134 | func (s claimEffectStore) Delete(context.Context, string) error { return hostops.ErrContract } |
| 135 | func (s claimEffectStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 136 | _, err := s.effects.run(ctx, "control-object:"+key, "delete", map[string]string{"key": key, "expected_etag": etag}, func(ctx context.Context) (string, error) { |
| 137 | if err := release.DeleteIfMatch(ctx, s.data, key, etag); err != nil { |
| 138 | return "", err |
| 139 | } |
| 140 | return "joined", nil |
| 141 | }) |
| 142 | return err |
| 143 | } |
| 144 | |
| 145 | func claimStoreAdapters(ctx context.Context, store release.Store, p objstore.Provisioner) (release.Store, objstore.Provisioner) { |
| 146 | e, ok := ctx.Value(claimEffectContextKey{}).(claimStoreEffects) |
| 147 | if !ok { |
| 148 | return store, p |
| 149 | } |
| 150 | var prov objstore.Provisioner |
| 151 | if p != nil { |
| 152 | prov = claimEffectProvisioner{base: p, effects: e} |
| 153 | } |
| 154 | return claimEffectStore{data: store, effects: e}, prov |
| 155 | } |
| 156 | |