| 1 | package appconfig |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // Issue #213: a box takes an instance's lease only if it can provide that |
| 15 | // instance's config. These tests pin the predicate the activator and the |
| 16 | // reconcile pass both ask. |
| 17 | |
| 18 | type canProvideEnv struct { |
| 19 | t *testing.T |
| 20 | s release.Store |
| 21 | priv *ecdh.PrivateKey |
| 22 | pub *ecdh.PublicKey |
| 23 | other *ecdh.PrivateKey // a key this deployment never sealed for |
| 24 | } |
| 25 | |
| 26 | func newCanProvideEnv(t *testing.T) *canProvideEnv { |
| 27 | t.Helper() |
| 28 | priv, err := ecdh.P256().GenerateKey(randReader) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | other, err := ecdh.P256().GenerateKey(randReader) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | return &canProvideEnv{t: t, s: release.NewDirStore(t.TempDir()), priv: priv, pub: priv.PublicKey(), other: other} |
| 37 | } |
| 38 | |
| 39 | func (e *canProvideEnv) put(key string, m Manifest) { |
| 40 | e.t.Helper() |
| 41 | m.Schema = 1 |
| 42 | body, err := json.Marshal(m) |
| 43 | if err != nil { |
| 44 | e.t.Fatal(err) |
| 45 | } |
| 46 | if _, err := e.s.Put(context.Background(), key, body, ""); err != nil { |
| 47 | e.t.Fatal(err) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func (e *canProvideEnv) secret(plain string) Var { |
| 52 | e.t.Helper() |
| 53 | env, err := SealUnbound(e.pub, []byte(plain)) |
| 54 | if err != nil { |
| 55 | e.t.Fatal(err) |
| 56 | } |
| 57 | return Var{Kind: "secret", Envelope: env} |
| 58 | } |
| 59 | |
| 60 | func reasonOf(t *testing.T, err error) string { |
| 61 | t.Helper() |
| 62 | var r *ConfigRefusal |
| 63 | if !errors.As(err, &r) { |
| 64 | t.Fatalf("err = %v, want a *ConfigRefusal", err) |
| 65 | } |
| 66 | return r.Reason |
| 67 | } |
| 68 | |
| 69 | func TestCanProvideConfigNothingSealedNeedsNothing(t *testing.T) { |
| 70 | e := newCanProvideEnv(t) |
| 71 | ctx := context.Background() |
| 72 | // No manifest anywhere: a dev deployment. No key either. |
| 73 | if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com"); err != nil { |
| 74 | t.Fatalf("no manifest, no key: %v", err) |
| 75 | } |
| 76 | // A manifest with plain vars only has no sealed config. |
| 77 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) |
| 78 | if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com"); err != nil { |
| 79 | t.Fatalf("plain-only manifest, no key: %v", err) |
| 80 | } |
| 81 | // A manifest that was fully unset exists but is empty. |
| 82 | e.put(Key("bab", "other"), Manifest{App: "other", Vars: map[string]Var{}}) |
| 83 | if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "other", "", "a.example.com"); err != nil { |
| 84 | t.Fatalf("empty manifest, no key: %v", err) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestCanProvideConfigSealedNeedsAKeyThatOpensIt(t *testing.T) { |
| 89 | e := newCanProvideEnv(t) |
| 90 | ctx := context.Background() |
| 91 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": e.secret("pw")}}) |
| 92 | |
| 93 | if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { |
| 94 | t.Fatalf("right key: %v", err) |
| 95 | } |
| 96 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com")); got != ReasonNoKey { |
| 97 | t.Errorf("absent key reason = %q, want %q", got, ReasonNoKey) |
| 98 | } |
| 99 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyUnparseable, "bab", "shop", "", "a.example.com")); got != ReasonKeyUnparseable { |
| 100 | t.Errorf("unparseable key reason = %q, want %q", got, ReasonKeyUnparseable) |
| 101 | } |
| 102 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com")); got != ReasonCannotOpen { |
| 103 | t.Errorf("wrong key reason = %q, want %q", got, ReasonCannotOpen) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The instance overlay and the bound environment are part of what the box |
| 108 | // renders, so a secret only there still needs the key. |
| 109 | func TestCanProvideConfigLooksAtEveryLayerTheBoxRenders(t *testing.T) { |
| 110 | e := newCanProvideEnv(t) |
| 111 | ctx := context.Background() |
| 112 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) |
| 113 | e.put(EnvironmentKey("bab", "shop", "staging"), Manifest{App: "shop", Vars: map[string]Var{"S": e.secret("x")}}) |
| 114 | e.put(InstanceConfigKey("bab", "shop", "cust.example.com"), Manifest{App: "shop", Vars: map[string]Var{"T": e.secret("y")}}) |
| 115 | |
| 116 | // Unbound route, no overlay for this host: nothing sealed. |
| 117 | if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "plain.example.com"); err != nil { |
| 118 | t.Errorf("unbound plain host: %v", err) |
| 119 | } |
| 120 | // Bound to the environment: sealed. |
| 121 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "staging", "plain.example.com")); got != ReasonNoKey { |
| 122 | t.Errorf("environment secret reason = %q", got) |
| 123 | } |
| 124 | // The overlay for cust.example.com only. |
| 125 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "cust.example.com")); got != ReasonNoKey { |
| 126 | t.Errorf("overlay secret reason = %q", got) |
| 127 | } |
| 128 | if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "cust.example.com"); err != nil { |
| 129 | t.Errorf("overlay with the key: %v", err) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // SyncExec writes the WHOLE config file or none of it (RenderEnv fails if any |
| 134 | // one decrypt fails), and instanceEnv accepts a missing file. So a manifest |
| 135 | // that is only partly openable on a box with no rendered file would start the |
| 136 | // instance with none of its sealed config: fresh admission needs every |
| 137 | // envelope to open. |
| 138 | func TestCanProvideConfigRefusesPartlyOpenableConfigOnFreshAdmission(t *testing.T) { |
| 139 | ctx := context.Background() |
| 140 | cases := map[string]func(e *canProvideEnv, foreign string) map[string]Var{ |
| 141 | "one foreign-key envelope": func(e *canProvideEnv, f string) map[string]Var { |
| 142 | return map[string]Var{"A": {Kind: "secret", Envelope: f}, "C": e.secret("ok")} |
| 143 | }, |
| 144 | "one corrupt envelope": func(e *canProvideEnv, f string) map[string]Var { |
| 145 | return map[string]Var{"B": {Kind: "secret", Envelope: "not-base64!!"}, "C": e.secret("ok")} |
| 146 | }, |
| 147 | "an unwritable plain value": func(e *canProvideEnv, f string) map[string]Var { |
| 148 | return map[string]Var{"P": {Kind: "env", Value: "has \"quote"}, "C": e.secret("ok")} |
| 149 | }, |
| 150 | "all foreign": func(e *canProvideEnv, f string) map[string]Var { |
| 151 | return map[string]Var{"A": {Kind: "secret", Envelope: f}} |
| 152 | }, |
| 153 | } |
| 154 | for name, mk := range cases { |
| 155 | t.Run(name, func(t *testing.T) { |
| 156 | e := newCanProvideEnv(t) |
| 157 | foreign, err := SealUnbound(e.other.PublicKey(), []byte("z")) |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | vars := mk(e, foreign) |
| 162 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: vars}) |
| 163 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com")); got != ReasonCannotOpen { |
| 164 | t.Errorf("reason = %q, want cannot_open", got) |
| 165 | } |
| 166 | }) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // A v2 envelope is bound to its (account, app): the guard opens it under the |
| 171 | // route's own binding, as the sync pass does. |
| 172 | func TestCanProvideConfigOpensBoundEnvelopesUnderTheRoutesBinding(t *testing.T) { |
| 173 | e := newCanProvideEnv(t) |
| 174 | ctx := context.Background() |
| 175 | env, err := SealBound(e.pub, Binding{Account: "bab", App: "shop"}, []byte("pw")) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": {Kind: "secret", Envelope: env}}}) |
| 180 | if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { |
| 181 | t.Fatalf("bound envelope, own app: %v", err) |
| 182 | } |
| 183 | // The same bytes copied under another app do not open there. |
| 184 | e.put(Key("bab", "thief"), Manifest{App: "thief", Vars: map[string]Var{"DB": {Kind: "secret", Envelope: env}}}) |
| 185 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "thief", "", "a.example.com")); got != ReasonCannotOpen { |
| 186 | t.Errorf("copied bound envelope reason = %q", got) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // failingStore fails reads of the keys it is told to, and delegates the rest. |
| 191 | type failingStore struct { |
| 192 | release.Store |
| 193 | failGet map[string]error |
| 194 | } |
| 195 | |
| 196 | func (f *failingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 197 | if err, ok := f.failGet[key]; ok { |
| 198 | return nil, "", err |
| 199 | } |
| 200 | return f.Store.Get(ctx, key) |
| 201 | } |
| 202 | |
| 203 | // "We could not tell" is never "there is no sealed config": a read error on |
| 204 | // any layer refuses, with or without a key. |
| 205 | func TestCanProvideConfigFailsClosedWhenItCannotRead(t *testing.T) { |
| 206 | e := newCanProvideEnv(t) |
| 207 | ctx := context.Background() |
| 208 | boom := errors.New("s3: 503 slow down") |
| 209 | e.put(EnvironmentKey("bab", "shop", "staging"), Manifest{App: "shop", Vars: map[string]Var{}}) |
| 210 | for name, key := range map[string]string{ |
| 211 | "default": Key("bab", "shop"), |
| 212 | "environment": EnvironmentKey("bab", "shop", "staging"), |
| 213 | "overlay": InstanceConfigKey("bab", "shop", "a.example.com"), |
| 214 | } { |
| 215 | t.Run(name, func(t *testing.T) { |
| 216 | fs := &failingStore{Store: e.s, failGet: map[string]error{key: boom}} |
| 217 | for _, k := range []struct { |
| 218 | priv *ecdh.PrivateKey |
| 219 | st KeyState |
| 220 | }{{e.priv, KeyOK}, {nil, KeyAbsent}} { |
| 221 | err := CanProvideConfig(ctx, fs, k.priv, k.st, "bab", "shop", "staging", "a.example.com") |
| 222 | if got := reasonOf(t, err); got != ReasonStoreError { |
| 223 | t.Errorf("reason = %q, want %q", got, ReasonStoreError) |
| 224 | } |
| 225 | if !errors.Is(err, boom) { |
| 226 | t.Errorf("err %v does not wrap the store error", err) |
| 227 | } |
| 228 | } |
| 229 | }) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // A manifest the box cannot parse and a bound environment that is gone are |
| 234 | // both "cannot tell". |
| 235 | func TestCanProvideConfigFailsClosedOnUnreadableConfig(t *testing.T) { |
| 236 | e := newCanProvideEnv(t) |
| 237 | ctx := context.Background() |
| 238 | if _, err := e.s.Put(ctx, Key("bab", "garbled"), []byte("{not json"), ""); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "garbled", "", "a.example.com")); got != ReasonStoreError { |
| 242 | t.Errorf("garbled manifest reason = %q", got) |
| 243 | } |
| 244 | e.put(Key("bab", "bound"), Manifest{App: "bound", Vars: map[string]Var{}}) |
| 245 | if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "bound", "gone", "a.example.com")); got != ReasonStoreError { |
| 246 | t.Errorf("missing bound environment reason = %q", got) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // A host that fails ValidInstanceHost cannot have an overlay (the sweeps read |
| 251 | // that as "no overlay"); the guard must answer, not error on the host shape. |
| 252 | func TestCanProvideConfigHostWithNoPossibleOverlay(t *testing.T) { |
| 253 | e := newCanProvideEnv(t) |
| 254 | ctx := context.Background() |
| 255 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) |
| 256 | if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "Mixed.Case.example.com"); err != nil { |
| 257 | t.Fatalf("host without a possible overlay: %v", err) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // Nothing secret leaves in the error: not the envelope, not the plaintext. |
| 262 | func TestConfigRefusalReasonIsAClosedWord(t *testing.T) { |
| 263 | e := newCanProvideEnv(t) |
| 264 | ctx := context.Background() |
| 265 | v := e.secret("hunter2") |
| 266 | e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": v}}) |
| 267 | err := CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com") |
| 268 | var r *ConfigRefusal |
| 269 | if !errors.As(err, &r) { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | switch r.Reason { |
| 273 | case ReasonNoKey, ReasonKeyUnparseable, ReasonCannotOpen, ReasonStoreError, ReasonNoSource: |
| 274 | default: |
| 275 | t.Errorf("reason %q is not one of the closed words", r.Reason) |
| 276 | } |
| 277 | if strings.Contains(err.Error(), v.Envelope) || strings.Contains(err.Error(), "hunter2") { |
| 278 | t.Errorf("error leaks sealed material: %v", err) |
| 279 | } |
| 280 | } |
| 281 | |