From a0539aada1367b63c6d8c76abb01a6a3c247ad35 Mon Sep 17 00:00:00 2001 From: vthwang Date: Mon, 21 Sep 2026 14:02:00 -0700 Subject: [PATCH 1/2] fix(api): filter ACL responses to super admins Keep complete ACL snapshots in the database while exposing only unrestricted super admins through user and admin endpoints. Update response documentation and cover filtering behavior. Signed-off-by: vthwang --- internal/apidocs/openapi.yaml | 29 ++++++++++++------- .../handler/admin_platform_stack_admins.go | 3 +- .../handler/admin_platform_stack_grant.go | 4 +-- .../admin_platform_stack_grant_test.go | 16 ++++++++++ internal/handler/setup_acl.go | 29 +++++++++++++------ 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/internal/apidocs/openapi.yaml b/internal/apidocs/openapi.yaml index 0318b80..5bf2ac9 100644 --- a/internal/apidocs/openapi.yaml +++ b/internal/apidocs/openapi.yaml @@ -115,6 +115,7 @@ components: properties: entries: type: array + description: Super Admin entries from the complete synchronized ACL snapshot. items: $ref: "#/components/schemas/VtaAclEntry" synced_at: @@ -2353,15 +2354,17 @@ paths: get: summary: Read the platform VTA's synchronized ACL description: | - Returns the last complete `vta acl list` snapshot stored for the - platform stack. Reading the snapshot does not stop the VTA. Before the - first refresh, `synced_at` is null and `entries` is empty. + Returns the Super Admin entries from the last complete `vta acl list` + snapshot stored for the platform stack. The database retains every ACL + entry; non-Super-Admin entries are filtered from this response. Reading + the snapshot does not stop the VTA. Before the first refresh, + `synced_at` is null and `entries` is empty. tags: [Admin] security: - CookieAuthAdmin: [] responses: "200": - description: Last synchronized ACL snapshot + description: Super Admin entries from the last synchronized ACL snapshot content: application/json: schema: @@ -2493,7 +2496,8 @@ paths: summary: Refresh the platform VTA's ACL snapshot description: | Stops the platform VTA, runs `vta acl list` against its local store, - atomically replaces the database snapshot, then restarts the VTA. + atomically replaces the complete database snapshot, then restarts the + VTA. The response includes only Super Admin entries. tags: [Admin] security: - CookieAuthAdmin: [] @@ -5189,9 +5193,11 @@ paths: get: summary: Read the synchronized VTA ACL description: | - Returns the last complete `vta acl list` snapshot stored for the - authenticated user's VTA. This does not stop the VTA. Before the first - refresh, `synced_at` is null and `entries` is empty. + Returns the Super Admin entries from the last complete `vta acl list` + snapshot stored for the authenticated user's VTA. The database retains + every ACL entry; non-Super-Admin entries are filtered from this + response. This does not stop the VTA. Before the first refresh, + `synced_at` is null and `entries` is empty. tags: [User] security: - CookieAuthUser: [] @@ -5203,7 +5209,7 @@ paths: type: string responses: "200": - description: Last synchronized ACL snapshot + description: Super Admin entries from the last synchronized ACL snapshot content: application/json: schema: @@ -5305,8 +5311,9 @@ paths: summary: Refresh the VTA ACL snapshot description: | Stops the authenticated user's VTA, runs `vta acl list` against its - local store, atomically replaces the database snapshot, then restarts - the VTA. The operation is synchronous and takes about one minute. + local store, atomically replaces the complete database snapshot, then + restarts the VTA. The response includes only Super Admin entries. The + operation is synchronous and takes about one minute. tags: [User] security: - CookieAuthUser: [] diff --git a/internal/handler/admin_platform_stack_admins.go b/internal/handler/admin_platform_stack_admins.go index 2699d36..28e354c 100644 --- a/internal/handler/admin_platform_stack_admins.go +++ b/internal/handler/admin_platform_stack_admins.go @@ -121,7 +121,8 @@ func (h *SetupHandler) platformSession(c *gin.Context) *model.SetupSession { } // ListPlatformStackAdmins — GET /api/v1/admin/platform-stack/admins. -// Serves the last complete `vta acl list` snapshot without causing downtime. +// Serves the super admins from the last complete `vta acl list` snapshot +// without causing downtime. func (h *SetupHandler) ListPlatformStackAdmins(c *gin.Context) { session := h.platformSession(c) if session == nil { diff --git a/internal/handler/admin_platform_stack_grant.go b/internal/handler/admin_platform_stack_grant.go index 13c360c..af3b192 100644 --- a/internal/handler/admin_platform_stack_grant.go +++ b/internal/handler/admin_platform_stack_grant.go @@ -93,10 +93,10 @@ func (h *SetupHandler) grantVtaAdmin( warnings := make([]string, 0, 2) if entries, parseErr := parseVtaAclList(logs); parseErr != nil { - warnings = append(warnings, "The PNM was linked, but the ACL snapshot could not be parsed. Use Refresh live ACL to retry.") + warnings = append(warnings, "The PNM was linked, but the ACL snapshot could not be parsed. Use Refresh ACL to retry.") } else if syncErr := h.syncSessionAclSnapshot(session.ID, entries); syncErr != nil { log.Printf("[vta-admins] error: failed to sync ACL snapshot for session %d: %v", session.ID, syncErr) - warnings = append(warnings, "The PNM was linked, but the ACL snapshot could not be saved. Use Refresh live ACL to retry.") + warnings = append(warnings, "The PNM was linked, but the ACL snapshot could not be saved. Use Refresh ACL to retry.") } resp := gin.H{ diff --git a/internal/handler/admin_platform_stack_grant_test.go b/internal/handler/admin_platform_stack_grant_test.go index b61ccaa..ac498b0 100644 --- a/internal/handler/admin_platform_stack_grant_test.go +++ b/internal/handler/admin_platform_stack_grant_test.go @@ -146,6 +146,22 @@ func TestSortVtaAclEntriesNewestFirst(t *testing.T) { } } +func TestSuperAdminAclEntriesFiltersResponseWithoutMutatingSnapshot(t *testing.T) { + entries := []model.VtaAclEntry{ + {Did: "did:key:zSuperAdmin", Role: superAdminAclRole}, + {Did: "did:key:zScopedAdmin", Role: "admin"}, + {Did: "did:key:zApplication", Role: "application"}, + } + + filtered := superAdminAclEntries(entries) + if len(filtered) != 1 || filtered[0].Did != "did:key:zSuperAdmin" { + t.Fatalf("superAdminAclEntries() = %#v, want only the super admin", filtered) + } + if len(entries) != 3 { + t.Fatalf("superAdminAclEntries() mutated the complete snapshot: got %d entries, want 3", len(entries)) + } +} + // A label identifies the entry after PNM rotates the DID away. It is optional, // so an empty value must omit the flag rather than pass an empty string. func TestGrantCmdCarriesTheLabel(t *testing.T) { diff --git a/internal/handler/setup_acl.go b/internal/handler/setup_acl.go index 127ca47..ee65f8a 100644 --- a/internal/handler/setup_acl.go +++ b/internal/handler/setup_acl.go @@ -27,9 +27,11 @@ type sessionAclResponse struct { Warning string `json:"warning,omitempty"` } -// ListSessionAdmins returns the last complete VTA ACL snapshot without causing -// downtime. An absent snapshot is represented by a nil timestamp and empty -// entries, so the portal can offer the first refresh. +const superAdminAclRole = "admin (super admin)" + +// ListSessionAdmins returns the super admins from the last complete VTA ACL +// snapshot without causing downtime. An absent snapshot is represented by a +// nil timestamp and empty entries, so the portal can offer the first refresh. func (h *SetupHandler) ListSessionAdmins(c *gin.Context) { session := h.userSession(c) if session == nil { @@ -217,20 +219,29 @@ func (h *SetupHandler) sessionAclSnapshot(sessionID uint) (sessionAclResponse, e if err != nil { return response, err } + var entries []model.VtaAclEntry response.SyncedAt = snapshot.SyncedAt - if err := h.db.Where("session_id = ?", sessionID).Order("did ASC").Find(&response.Entries).Error; err != nil { + if err := h.db.Where("session_id = ?", sessionID).Order("did ASC").Find(&entries).Error; err != nil { return response, err } - if response.Entries == nil { - response.Entries = []model.VtaAclEntry{} - } - if len(response.Entries) != snapshot.EntryCount { - return response, fmt.Errorf("ACL snapshot expected %d entries, found %d", snapshot.EntryCount, len(response.Entries)) + if len(entries) != snapshot.EntryCount { + return response, fmt.Errorf("ACL snapshot expected %d entries, found %d", snapshot.EntryCount, len(entries)) } + response.Entries = superAdminAclEntries(entries) sortVtaAclEntriesNewestFirst(response.Entries) return response, nil } +func superAdminAclEntries(entries []model.VtaAclEntry) []model.VtaAclEntry { + filtered := make([]model.VtaAclEntry, 0, len(entries)) + for _, entry := range entries { + if entry.Role == superAdminAclRole { + filtered = append(filtered, entry) + } + } + return filtered +} + func sortVtaAclEntriesNewestFirst(entries []model.VtaAclEntry) { const layout = "2006-01-02 15:04:05 -07:00" sort.SliceStable(entries, func(i, j int) bool { From 0bbcbf58d73f43680a7fd7a807df951a88157401 Mon Sep 17 00:00:00 2001 From: vthwang Date: Mon, 21 Sep 2026 14:05:49 -0700 Subject: [PATCH 2/2] chore: release 0.6.1 Signed-off-by: vthwang --- CHANGELOG.md | 8 ++++++++ helm/vtafarm-api/Chart.yaml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e84a5..262b245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v0.6.1] - 2026-09-21 + +### Changed + +- User and admin ACL endpoints now return only unrestricted Super Admin + entries. Complete synchronized ACL snapshots continue to be retained in the + database. + ## [v0.6.0] - 2026-09-21 ### Added diff --git a/helm/vtafarm-api/Chart.yaml b/helm/vtafarm-api/Chart.yaml index 6de3f12..0578cd3 100644 --- a/helm/vtafarm-api/Chart.yaml +++ b/helm/vtafarm-api/Chart.yaml @@ -3,5 +3,5 @@ name: vtafarm-api description: VTA Farm API — Go REST backend for Kubernetes pod management type: application # Kept equal; one release bumps both. -version: 0.6.0 -appVersion: "0.6.0" +version: 0.6.1 +appVersion: "0.6.1"