diff --git a/services/api/authz.go b/services/api/authz.go index 9b0163b..7aa22d6 100644 --- a/services/api/authz.go +++ b/services/api/authz.go @@ -13,6 +13,7 @@ const ( type principal struct { Role string `json:"role"` Subject string `json:"subject,omitempty"` + UserID string `json:"-"` Email string `json:"email,omitempty"` Namespace string `json:"namespace,omitempty"` AuthType string `json:"auth_type,omitempty"` @@ -21,6 +22,9 @@ type principal struct { } func (p principal) userID() string { + if userID := strings.TrimSpace(p.UserID); userID != "" { + return userID + } return strings.TrimSpace(p.Subject) } diff --git a/services/api/authz_test.go b/services/api/authz_test.go index a4087b4..5e43457 100644 --- a/services/api/authz_test.go +++ b/services/api/authz_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -76,3 +77,58 @@ func TestRequireRole(t *testing.T) { t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) } } + +func TestPrincipalUserIDPrefersInternalUserID(t *testing.T) { + p := principal{ + Subject: "google-sub-123", + UserID: "6d5d8c5a-4c8d-4e50-9e34-3d6439f1aa55", + } + + if got := p.userID(); got != p.UserID { + t.Fatalf("userID() = %q, want %q", got, p.UserID) + } + + legacy := principal{Subject: "legacy-subject"} + if got := legacy.userID(); got != "legacy-subject" { + t.Fatalf("legacy userID() = %q, want legacy-subject", got) + } +} + +func TestHandleAuthMeReturnsPrincipalSubject(t *testing.T) { + srv := &apiServer{} + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + req = req.WithContext(context.WithValue(req.Context(), principalContextKey{}, principal{ + Role: roleUser, + Subject: "google-sub-123", + UserID: "6d5d8c5a-4c8d-4e50-9e34-3d6439f1aa55", + Email: "user@example.com", + Namespace: "user-17", + })) + rec := httptest.NewRecorder() + + srv.handleAuthMe(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var payload struct { + Authenticated bool `json:"authenticated"` + Principal struct { + Subject string `json:"subject"` + Email string `json:"email"` + } `json:"principal"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !payload.Authenticated { + t.Fatal("authenticated = false, want true") + } + if payload.Principal.Subject != "google-sub-123" { + t.Fatalf("principal.subject = %q, want google-sub-123", payload.Principal.Subject) + } + if payload.Principal.Email != "user@example.com" { + t.Fatalf("principal.email = %q, want user@example.com", payload.Principal.Email) + } +} diff --git a/services/api/main.go b/services/api/main.go index 29843b1..3611326 100644 --- a/services/api/main.go +++ b/services/api/main.go @@ -684,7 +684,8 @@ func (s *apiServer) authenticateRequest(r *http.Request) (principal, bool, error } return principal{ Role: u.Role, - Subject: u.ID, + Subject: sub, + UserID: u.ID, Email: u.Email, Namespace: u.Namespace, AuthType: "oidc_jwt", diff --git a/services/api/platform_auth.go b/services/api/platform_auth.go index 8ae7913..cadb129 100644 --- a/services/api/platform_auth.go +++ b/services/api/platform_auth.go @@ -183,7 +183,7 @@ func (s *apiServer) handleSignup(w http.ResponseWriter, r *http.Request) { return } if s.runtime != nil { - if err := s.runtime.ensureUserNamespace(r.Context(), principal{Subject: u.ID, Role: u.Role, Email: u.Email, Namespace: u.Namespace}); err != nil { + if err := s.runtime.ensureUserNamespace(r.Context(), principal{Subject: u.ID, UserID: u.ID, Role: u.Role, Email: u.Email, Namespace: u.Namespace}); err != nil { s.platform.WriteAudit(r.Context(), auditEvent{UserID: u.ID, Action: "namespace_create", Resource: u.Namespace, Namespace: u.Namespace, Status: "error", Message: err.Error(), ActorIP: requestIP(r)}) if cleanupErr := s.platform.DeleteUser(r.Context(), u.ID); cleanupErr != nil { log.Printf("signup cleanup failed for user %s: %v", u.ID, cleanupErr) diff --git a/services/api/platform_store.go b/services/api/platform_store.go index bfde655..a30c79f 100644 --- a/services/api/platform_store.go +++ b/services/api/platform_store.go @@ -445,10 +445,11 @@ SELECT u.id, u.email, u.role, COALESCE(n.namespace, '') FROM users u LEFT JOIN namespaces n ON n.user_id = u.id AND n.deleted_at IS NULL WHERE u.id = $1 AND u.deleted_at IS NULL`, subject). - Scan(&p.Subject, &p.Email, &p.Role, &p.Namespace) + Scan(&p.UserID, &p.Email, &p.Role, &p.Namespace) if err != nil { return principal{}, false } + p.Subject = p.UserID p.AuthType = "platform_jwt" return p, true } @@ -470,7 +471,7 @@ WHERE ak.key_hash = $1 AND ak.revoked = false`, targetHash). return principal{}, false, err } _, _ = s.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = now() WHERE id = $1 AND (last_used_at IS NULL OR last_used_at < now() - interval '5 minutes')`, keyID) - return principal{Role: role, Subject: userID, Email: email, Namespace: namespace, AuthType: "user_api_key", APIKeyID: keyID}, true, nil + return principal{Role: role, Subject: userID, UserID: userID, Email: email, Namespace: namespace, AuthType: "user_api_key", APIKeyID: keyID}, true, nil } func (s *platformStore) ListUserAPIKeys(ctx context.Context, userID string) ([]userAPIKeySummary, error) { diff --git a/services/api/user_keys.go b/services/api/user_keys.go index 171c861..5a46214 100644 --- a/services/api/user_keys.go +++ b/services/api/user_keys.go @@ -86,6 +86,7 @@ func (s *RuntimeServer) AuthenticateUserAPIKey(ctx context.Context, rawKey strin return principal{ Role: roleUser, Subject: rec.UserID, + UserID: rec.UserID, AuthType: "user_api_key", APIKeyID: rec.ID, }, true, nil diff --git a/services/api/user_keys_test.go b/services/api/user_keys_test.go index 4b702e9..ca36c4d 100644 --- a/services/api/user_keys_test.go +++ b/services/api/user_keys_test.go @@ -2,6 +2,9 @@ package main import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -17,3 +20,58 @@ func TestListUserAPIKeysReturnsUnavailableWhenKubernetesUnavailable(t *testing.T t.Fatalf("ListUserAPIKeys() error = %v, want substring %q", err, "kubernetes not available") } } + +type recordingUserAPIKeyStore struct { + listUserID string + listKeys []userAPIKeySummary +} + +func (s *recordingUserAPIKeyStore) AuthenticateUserAPIKey(context.Context, string) (principal, bool, error) { + return principal{}, false, nil +} + +func (s *recordingUserAPIKeyStore) ListUserAPIKeys(_ context.Context, userID string) ([]userAPIKeySummary, error) { + s.listUserID = userID + return s.listKeys, nil +} + +func (s *recordingUserAPIKeyStore) CreateUserAPIKey(context.Context, string, string) (userAPIKeySummary, string, error) { + return userAPIKeySummary{}, "", nil +} + +func (s *recordingUserAPIKeyStore) RevokeUserAPIKey(context.Context, string, string) (userAPIKeySummary, error) { + return userAPIKeySummary{}, nil +} + +func TestHandleUserAPIKeysUsesInternalUserID(t *testing.T) { + store := &recordingUserAPIKeyStore{ + listKeys: []userAPIKeySummary{{ID: "uk_1", Name: "default", Prefix: "mcpu_123"}}, + } + server := &apiServer{userKeys: store} + req := httptest.NewRequest(http.MethodGet, "/api/user/api-keys", nil) + req = req.WithContext(context.WithValue(req.Context(), principalContextKey{}, principal{ + Role: roleUser, + Subject: "google-sub-123", + UserID: "6d5d8c5a-4c8d-4e50-9e34-3d6439f1aa55", + })) + rec := httptest.NewRecorder() + + server.handleUserAPIKeys(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if store.listUserID != "6d5d8c5a-4c8d-4e50-9e34-3d6439f1aa55" { + t.Fatalf("ListUserAPIKeys called with %q, want internal user id", store.listUserID) + } + + var payload struct { + Keys []userAPIKeySummary `json:"keys"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Keys) != 1 || payload.Keys[0].ID != "uk_1" { + t.Fatalf("keys = %#v, want one returned key", payload.Keys) + } +}