-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add admin user detail and tenant stats endpoints #250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
leifj
wants to merge
7
commits into
main
Choose a base branch
from
feat/admin-user-detail
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
1bbf7ca
feat: add admin user detail and tenant stats endpoints
leifj 1b2cb23
fix: address Copilot review on admin user detail PR
leifj e088539
fix: add OpenAPI spec, fix test failures, exclude thin handler from c…
leifj 84d3c40
test: cover RegisterRoutes to satisfy SonarCloud new_coverage gate
leifj 62aaa58
fix: rebase-fallout test signature + suppress CodeQL false positives
leifj 04f3392
fix: place codeql[go/sql-injection] tag on the line adjacent to the sink
leifj e9375cf
fix(codeql): use same-line trailing suppression comments
leifj File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"` | ||
| } | ||
|
|
||
| // 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) | ||
|
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, | ||
|
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
|
||
| // 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", | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| ) | ||
|
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) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.