Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/openapi-admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions internal/api/admin_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
26 changes: 26 additions & 0 deletions internal/api/admin_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
126 changes: 126 additions & 0 deletions internal/api/admin_user_handlers.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Comment thread
leifj marked this conversation as resolved.

// 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)
Comment thread
Copilot marked this conversation as resolved.
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,
Comment thread
Copilot marked this conversation as resolved.
})
}

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

Check warning on line 120 in internal/api/admin_user_handlers.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=sirosfoundation_go-wallet-backend&issues=AZ9mHUaiD0idQcaAvoIF&open=AZ9mHUaiD0idQcaAvoIF&pullRequest=250
// 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",
})
}
122 changes: 122 additions & 0 deletions internal/api/admin_user_handlers_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Comment thread
Copilot marked this conversation as resolved.

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)
}
}
8 changes: 7 additions & 1 deletion internal/storage/mongodb/mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,13 @@

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]

Check failure on line 296 in internal/storage/mongodb/mongodb.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "_id.id" 3 times.

See more on https://sonarcloud.io/project/issues?id=sirosfoundation_go-wallet-backend&issues=AZ_SWTRBsDievb3PGcPR&open=AZ_SWTRBsDievb3PGcPR&pullRequest=250
Comment thread
leifj marked this conversation as resolved.
Dismissed
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, storage.ErrNotFound
Expand Down
11 changes: 7 additions & 4 deletions internal/storage/mongodb/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,13 @@
}

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]
Comment thread
leifj marked this conversation as resolved.
Dismissed
if err != nil {
return false, fmt.Errorf("failed to check membership: %w", err)
}
Expand Down
Loading