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" 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 {