mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +00:00
feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external agents authenticate without a browser session — the agent-first spine from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown once, only SHA-256 hash stored. - user_tokens table (goose migration + sqlc), name/prefix/scope/expiry, owner-scoped revoke, last_used_at stamping - middleware: cnp_ bearer resolution, read scope gates mutations with 403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin owners — a token never outranks its scope or its owner - GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types - Settings UI: create with scope/expiry pickers, one-time token display with copy, list with last-used/expiry, confirm-revoke - docs: OPERATIONS.md section, README feature line Verified end-to-end against live Postgres: create/list/revoke, read scope blocks PUT (403), write PAT on admin owner blocked from admin routes (403), admin scope works, revoked token returns 401. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
f9ae5b962c
commit
e881747388
17 files changed
+1141
-30
No files matched your search
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(Auth("secret"))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
|
||||
// db handle it must fail closed with 401.
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATScopeAllows(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scope string
|
||||
method string
|
||||
want bool
|
||||
}{
|
||||
{"read allows GET", "read", http.MethodGet, true},
|
||||
{"read allows HEAD", "read", http.MethodHead, true},
|
||||
{"read allows OPTIONS", "read", http.MethodOptions, true},
|
||||
{"read blocks POST", "read", http.MethodPost, false},
|
||||
{"read blocks DELETE", "read", http.MethodDelete, false},
|
||||
{"write allows POST", "write", http.MethodPost, true},
|
||||
{"write allows DELETE", "write", http.MethodDelete, true},
|
||||
{"admin allows PATCH", "admin", http.MethodPatch, true},
|
||||
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(tc.method, "/x", nil)
|
||||
if tc.scope != "" {
|
||||
c.Set("pat_scope", tc.scope)
|
||||
}
|
||||
if got := patScopeAllows(c); got != tc.want {
|
||||
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
Reference in new issue
Block a user