Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions helm/vtafarm-api/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
29 changes: 18 additions & 11 deletions internal/apidocs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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: []
Expand Down Expand Up @@ -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: []
Expand All @@ -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:
Expand Down Expand Up @@ -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: []
Expand Down
3 changes: 2 additions & 1 deletion internal/handler/admin_platform_stack_admins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/handler/admin_platform_stack_grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
16 changes: 16 additions & 0 deletions internal/handler/admin_platform_stack_grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 20 additions & 9 deletions internal/handler/setup_acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading