diff --git a/docs/openapi-admin.yaml b/docs/openapi-admin.yaml index aad80597..c62fdd8a 100644 --- a/docs/openapi-admin.yaml +++ b/docs/openapi-admin.yaml @@ -939,6 +939,68 @@ paths: '500': $ref: '#/components/responses/InternalError' + /admin/tenants/{tenantId}/users/{userId}/detail: + get: + tags: + - Users + summary: Get user detail + description: | + Returns extended user details including passkey information, DID, and wallet type. + Only non-PII fields are exposed. + operationId: getUserDetail + parameters: + - $ref: '#/components/parameters/tenantId' + - $ref: '#/components/parameters/userId' + responses: + '200': + description: User detail + content: + application/json: + schema: + type: object + properties: + uuid: + type: string + did: + type: string + wallet_type: + type: string + passkeys: + type: array + items: + type: object + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + '404': + $ref: '#/components/responses/NotFound' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalError' + + /admin/tenants/{tenantId}/stats: + get: + tags: + - Tenants + summary: Get tenant statistics + description: Returns aggregated statistics for a tenant. Not yet implemented. + operationId: getTenantStats + parameters: + - $ref: '#/components/parameters/tenantId' + responses: + '501': + description: Not yet implemented + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + components: parameters: tenantId: diff --git a/internal/api/admin_handlers.go b/internal/api/admin_handlers.go index 20830c39..e135c025 100644 --- a/internal/api/admin_handlers.go +++ b/internal/api/admin_handlers.go @@ -1083,6 +1083,12 @@ func (h *AdminHandlers) RegisterRoutes(adminGroup *gin.RouterGroup) { tenants.PUT("/:id/instances/:instance_id/status", h.UpdateWalletInstanceStatus) tenants.DELETE("/:id/instances/:instance_id", h.DeleteWalletInstance) tenants.GET("/:id/users/:user_id/instances", h.ListWalletInstancesByUser) + + // User detail + tenants.GET("/:id/users/:user_id/detail", h.GetUserDetail) + + // Tenant statistics + tenants.GET("/:id/stats", h.GetTenantStats) } } diff --git a/internal/api/admin_handlers_test.go b/internal/api/admin_handlers_test.go index af8cf6bd..ca594536 100644 --- a/internal/api/admin_handlers_test.go +++ b/internal/api/admin_handlers_test.go @@ -1006,3 +1006,29 @@ func TestTenantToResponse(t *testing.T) { // tenantToResponse is tested implicitly through the handlers } + +func TestAdminHandlers_RegisterRoutes(t *testing.T) { + handlers, router := setupAdminTestHandlers(t) + adminGroup := router.Group("/admin") + handlers.RegisterRoutes(adminGroup) + + wantPaths := map[string]bool{ + "/admin/tenants": false, + "/admin/tenants/:id": false, + "/admin/tenants/:id/users": false, + "/admin/tenants/:id/users/:user_id": false, + "/admin/tenants/:id/users/:user_id/detail": false, + "/admin/tenants/:id/stats": false, + "/admin/tenants/:id/invites": false, + } + for _, ri := range router.Routes() { + if _, ok := wantPaths[ri.Path]; ok { + wantPaths[ri.Path] = true + } + } + for path, found := range wantPaths { + if !found { + t.Errorf("expected route %q to be registered", path) + } + } +} diff --git a/internal/api/admin_user_handlers.go b/internal/api/admin_user_handlers.go new file mode 100644 index 00000000..2fe3c266 --- /dev/null +++ b/internal/api/admin_user_handlers.go @@ -0,0 +1,126 @@ +package api + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/storage" +) + +// UserDetailResponse contains user information visible to admin. +// Only non-PII fields are exposed: UUID (opaque), did:key (public key), passkeys. +type UserDetailResponse struct { + UUID string `json:"uuid"` + DID string `json:"did,omitempty"` + WalletType string `json:"wallet_type"` + Passkeys []PasskeyInfo `json:"passkeys"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PasskeyInfo is the admin-visible summary of a WebAuthn credential. +// Only non-PII fields are exposed; user-provided text (e.g. nickname) is omitted. +type PasskeyInfo struct { + ID string `json:"id"` + CredentialID string `json:"credential_id"` // base64url + TenantID string `json:"tenant_id"` + AttestationType string `json:"attestation_type"` + Transport []string `json:"transport,omitempty"` + PRFCapable bool `json:"prf_capable"` + SignCount uint32 `json:"sign_count"` + CreatedAt time.Time `json:"created_at"` + LastUseTime *time.Time `json:"last_use_time,omitempty"` +} + +// GetUserDetail returns detailed user information for admin. +// GET /admin/tenants/:id/users/:user_id/detail +func (h *AdminHandlers) GetUserDetail(c *gin.Context) { + tenantID := domain.TenantID(c.Param("id")) + userID := domain.UserIDFromString(c.Param("user_id")) + + // Verify tenant exists + _, err := h.store.Tenants().GetByID(c.Request.Context(), tenantID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"}) + return + } + h.logger.Error("failed to get tenant", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get tenant"}) + return + } + + // Verify user is a member of this tenant + isMember, err := h.store.UserTenants().IsMember(c.Request.Context(), userID, tenantID) + if err != nil { + h.logger.Error("failed to check tenant membership", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check membership"}) + return + } + if !isMember { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found in tenant"}) + return + } + + user, err := h.store.Users().GetByID(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + h.logger.Error("failed to get user", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) + return + } + + resp := UserDetailResponse{ + UUID: user.UUID.String(), + WalletType: string(user.WalletType), + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + // Only expose DID if it's a did:key (public key encoding, not PII). + if strings.HasPrefix(user.DID, "did:key:") { + resp.DID = user.DID + } + + // Convert passkeys (filter to this tenant) + resp.Passkeys = make([]PasskeyInfo, 0) + for _, cred := range user.WebauthnCredentials { + if cred.TenantID != tenantID { + continue + } + resp.Passkeys = append(resp.Passkeys, PasskeyInfo{ + ID: cred.ID, + CredentialID: base64.RawURLEncoding.EncodeToString(cred.CredentialID), + TenantID: string(cred.TenantID), + AttestationType: cred.AttestationType, + Transport: cred.Transport, + PRFCapable: cred.PRFCapable, + SignCount: cred.Authenticator.SignCount, + CreatedAt: cred.CreatedAt, + LastUseTime: cred.LastUseTime, + }) + } + + c.JSON(http.StatusOK, resp) +} + +// GetTenantStats returns aggregate statistics for a tenant. +// GET /admin/tenants/:id/stats +// +// TODO: This endpoint requires dedicated statistics counters to avoid +// full-table scans. See https://github.com/sirosfoundation/go-wallet-backend/issues/223 +func (h *AdminHandlers) GetTenantStats(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{ + "error": "tenant statistics not yet implemented; requires dedicated counters", + }) +} diff --git a/internal/api/admin_user_handlers_test.go b/internal/api/admin_user_handlers_test.go new file mode 100644 index 00000000..1056b21b --- /dev/null +++ b/internal/api/admin_user_handlers_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/storage/memory" +) + +func TestGetUserDetail_TenantNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + store := memory.NewStore() + h := NewAdminHandlers(store, zap.NewNop(), nil) + router := gin.New() + router.GET("/admin/tenants/:id/users/:user_id/detail", h.GetUserDetail) + + req := httptest.NewRequest(http.MethodGet, "/admin/tenants/nonexistent/users/some-user/detail", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for missing tenant, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetUserDetail_NotMember(t *testing.T) { + gin.SetMode(gin.TestMode) + store := memory.NewStore() + h := NewAdminHandlers(store, zap.NewNop(), nil) + router := gin.New() + router.GET("/admin/tenants/:id/users/:user_id/detail", h.GetUserDetail) + + // Create tenant but don't add user as member + tenant := &domain.Tenant{ID: "acme", Name: "Acme Corp"} + if err := store.Tenants().Create(context.Background(), tenant); err != nil { + t.Fatalf("create tenant: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/tenants/acme/users/nonexistent/detail", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetUserDetail_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + store := memory.NewStore() + h := NewAdminHandlers(store, zap.NewNop(), nil) + router := gin.New() + router.GET("/admin/tenants/:id/users/:user_id/detail", h.GetUserDetail) + + // Create tenant + tenant := &domain.Tenant{ID: "acme", Name: "Acme Corp"} + if err := store.Tenants().Create(context.Background(), tenant); err != nil { + t.Fatalf("create tenant: %v", err) + } + + // Create user + user := &domain.User{ + UUID: domain.NewUserID(), + DID: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + WalletType: "web", + } + if err := store.Users().Create(context.Background(), user); err != nil { + t.Fatalf("create user: %v", err) + } + + // Add membership + membership := &domain.UserTenantMembership{ + UserID: user.UUID, + TenantID: "acme", + Role: "user", + } + if err := store.UserTenants().AddMembership(context.Background(), membership); err != nil { + t.Fatalf("add membership: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/tenants/acme/users/"+user.UUID.String()+"/detail", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp UserDetailResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.UUID != user.UUID.String() { + t.Errorf("expected UUID %s, got %s", user.UUID.String(), resp.UUID) + } + if resp.DID != user.DID { + t.Errorf("expected DID %s, got %s", user.DID, resp.DID) + } +} + +func TestGetTenantStats_NotImplemented(t *testing.T) { + gin.SetMode(gin.TestMode) + store := memory.NewStore() + h := NewAdminHandlers(store, zap.NewNop(), nil) + router := gin.New() + router.GET("/admin/tenants/:id/stats", h.GetTenantStats) + + req := httptest.NewRequest(http.MethodGet, "/admin/tenants/acme/stats", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotImplemented { + t.Fatalf("expected 501, got %d", w.Code) + } +} diff --git a/internal/storage/mongodb/mongodb.go b/internal/storage/mongodb/mongodb.go index e300d078..a1e36d10 100644 --- a/internal/storage/mongodb/mongodb.go +++ b/internal/storage/mongodb/mongodb.go @@ -287,7 +287,13 @@ func (s *UserStore) Create(ctx context.Context, user *domain.User) error { func (s *UserStore) GetByID(ctx context.Context, id domain.UserID) (*domain.User, error) { var user domain.User - err := s.collection.FindOne(ctx, bson.M{"_id.id": id.String()}).Decode(&user) + // False positive: bson.M is a typed document builder, not a query string. + // The key here ("_id.id") is a fixed literal; id.String() is only ever used + // as a plain field VALUE, which the driver BSON-encodes as a string and + // compares by equality. A string value can never be interpreted as a Mongo + // query operator (only "$"-prefixed map KEYS are), so untrusted input + // reaching this call site cannot inject query semantics. + err := s.collection.FindOne(ctx, bson.M{"_id.id": id.String()}).Decode(&user) // codeql[go/sql-injection] if err != nil { if err == mongo.ErrNoDocuments { return nil, storage.ErrNotFound diff --git a/internal/storage/mongodb/tenant.go b/internal/storage/mongodb/tenant.go index 53fffdcc..ce46d689 100644 --- a/internal/storage/mongodb/tenant.go +++ b/internal/storage/mongodb/tenant.go @@ -165,10 +165,13 @@ func (s *UserTenantStore) GetTenantUsers(ctx context.Context, tenantID domain.Te } func (s *UserTenantStore) IsMember(ctx context.Context, userID domain.UserID, tenantID domain.TenantID) (bool, error) { - count, err := s.collection.CountDocuments(ctx, bson.M{ - "user_id": userID.String(), - "tenant_id": string(tenantID), - }) + // False positive: bson.M is a typed document builder, not a query string. + // Both map keys ("user_id", "tenant_id") are fixed literals; userID and + // tenantID are only ever used as plain field VALUES, BSON-encoded as + // strings and compared by equality. A string value can never be + // interpreted as a Mongo query operator (only "$"-prefixed map KEYS are), + // so untrusted input reaching this call site cannot inject query semantics. + count, err := s.collection.CountDocuments(ctx, bson.M{"user_id": userID.String(), "tenant_id": string(tenantID)}) // codeql[go/sql-injection] if err != nil { return false, fmt.Errorf("failed to check membership: %w", err) }