console: pin fail-closed handling of an unknown stored role (#229)
A console that does not know a stored role (v0.21.0 does not know deploy)
answers its token exactly as it answers no token, at every registered CLI
door. The test stores role 'futurerole' on a deploy token and compares the
with-token and no-token answers per door.
v0.21.0 carries both refusals:
$ git show v0.21.0:internal/console/svcprincipal.go | grep -n -A3 'r, ok := svcrole.Parse(role)'
126: r, ok := svcrole.Parse(role)
127- if !ok {
128- return Principal{}, false
129- }
$ git show v0.21.0:internal/console/authcli.go | grep -n 'allow.Permits(p.Role)'
425: if p.Kind == PrincipalService && !allow.Permits(p.Role) {
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 file changed,
+80
−0
internal/console/deploymixed_test.go+80 −0
diff --git a/internal/console/deploymixed_test.go b/internal/console/deploymixed_test.go| new file mode 100644 |
| index 000000000..e2d03136c |
| --- /dev/null |
| +++ b/internal/console/deploymixed_test.go |
| @@ -0,0 +1,80 @@ |
| +package console |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +type rawReply struct { |
| + status int |
| + body string |
| +} |
| + |
| +// doRaw sends one request with an optional bearer token. Non-GET methods |
| +// carry the body {}. |
| +func doRaw(t *testing.T, method, url, tok string) rawReply { |
| + t.Helper() |
| + var body io.Reader |
| + if method != http.MethodGet && method != http.MethodHead { |
| + body = strings.NewReader("{}") |
| + } |
| + req, err := http.NewRequest(method, url, body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if body != nil { |
| + req.Header.Set("Content-Type", "application/json") |
| + } |
| + if tok != "" { |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + b, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rawReply{status: resp.StatusCode, body: string(b)} |
| +} |
| + |
| +// TestUnknownStoredRoleIsNotSignedInAtEveryDoor is spec §9's first check: a |
| +// console that does not know a stored role (as v0.21.0 does not know |
| +// deploy) answers its token exactly as it answers no token at all. |
| +func TestUnknownStoredRoleIsNotSignedInAtEveryDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE cli_tokens SET role = 'futurerole' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(fx.srv.cliRoles) == 0 { |
| + t.Fatal("no registered CLI doors to check") |
| + } |
| + fill := regexp.MustCompile(`\{[a-z]+\}`) |
| + for pattern := range fx.srv.cliRoles { |
| + method, path, _ := strings.Cut(pattern, " ") |
| + path = strings.Replace(path, "{account}", "bab", 1) |
| + path = strings.Replace(path, "{app}", "hello", 1) |
| + path = fill.ReplaceAllString(path, "x") |
| + with := doRaw(t, method, fx.ts.URL+path, raw) |
| + without := doRaw(t, method, fx.ts.URL+path, "") |
| + if with.status != without.status || with.body != without.body { |
| + t.Errorf("%s: unknown role answered %d %q; no token answered %d %q", |
| + pattern, with.status, with.body, without.status, without.body) |
| + } |
| + } |
| +} |