Files
Charon/docs/plans/current_spec.md
2026-01-29 03:15:06 +00:00

18 KiB

Access List Handler: UUID Support Plan

Version: 1.0 Created: January 29, 2026 Status: Ready for Implementation


Problem Statement

The Access List model uses a security design that hides the numeric ID from JSON responses:

// backend/internal/models/access_list.go:9-10
ID   uint   `json:"-" gorm:"primaryKey"`    // HIDDEN from JSON
UUID string `json:"uuid" gorm:"uniqueIndex"` // Exposed as "uuid"

However, all four handler endpoints (Get, Update, Delete, TestIP) only accept numeric IDs:

// backend/internal/api/handlers/access_list_handler.go:58, 79, 107, 131
id, err := strconv.ParseUint(c.Param("id"), 10, 32)

This causes E2E test failures because the JSON response returns uuid but tests attempt to use id:

  1. Line 138: lists[0].id is undefined because id is hidden from JSON
  2. Line 162: createdList.id is undefined for the same reason

Requirements (EARS Notation)

R1: UUID Path Parameter Support

WHEN a request is made to /api/v1/access-lists/:id endpoints, THE SYSTEM SHALL accept either a numeric ID or a UUID string in the :id path parameter.

R2: Backward Compatibility

WHEN a numeric ID is provided in the :id path parameter, THE SYSTEM SHALL resolve it to an access list using the existing GetByID() method.

R3: UUID Resolution

WHEN a non-numeric string is provided in the :id path parameter, THE SYSTEM SHALL attempt to resolve it as a UUID using the existing GetByUUID() method.

R4: Error Handling

IF neither numeric ID nor UUID matches an access list, THEN THE SYSTEM SHALL return HTTP 404 with error message "access list not found".

R5: Invalid Identifier

IF the :id parameter is empty or cannot be parsed as either uint or valid UUID format, THE SYSTEM SHALL return HTTP 400 with error message "invalid ID or UUID".


Technical Design

Approach: Helper Function in Handler

Create a helper function resolveAccessList() that:

  1. First attempts to parse as uint (numeric ID) for backward compatibility
  2. If parsing fails, treats it as UUID and calls GetByUUID()
  3. Returns the resolved *models.AccessList or appropriate error

This approach:

  • Minimizes code duplication across 4 handlers
  • Preserves backward compatibility with numeric IDs
  • Leverages existing GetByUUID() service method
  • Requires no service layer changes

Implementation Pattern

// resolveAccessList resolves an access list by either numeric ID or UUID.
// It first attempts to parse as uint (backward compatibility), then tries UUID.
func (h *AccessListHandler) resolveAccessList(idOrUUID string) (*models.AccessList, error) {
    // Try parsing as numeric ID first (backward compatibility)
    if id, err := strconv.ParseUint(idOrUUID, 10, 32); err == nil {
        return h.service.GetByID(uint(id))
    }

    // Try as UUID
    return h.service.GetByUUID(idOrUUID)
}

Implementation Tasks

Phase 1: Handler Modifications (Critical)

Task 1.1: Add resolveAccessList Helper

File: access_list_handler.go Location: After line 27 (after SetGeoIPService method) Priority: Critical

// resolveAccessList resolves an access list by either numeric ID or UUID.
// It first attempts to parse as uint (backward compatibility), then tries UUID.
func (h *AccessListHandler) resolveAccessList(idOrUUID string) (*models.AccessList, error) {
    // Try parsing as numeric ID first (backward compatibility)
    if id, err := strconv.ParseUint(idOrUUID, 10, 32); err == nil {
        return h.service.GetByID(uint(id))
    }

    // Empty string check
    if idOrUUID == "" {
        return nil, fmt.Errorf("invalid ID or UUID")
    }

    // Try as UUID
    return h.service.GetByUUID(idOrUUID)
}

Import Required: Add "fmt" to imports (line 4).

Task 1.2: Update Get Handler

File: access_list_handler.go Location: Lines 54-72 (Get method)

Current Code:

func (h *AccessListHandler) Get(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
        return
    }

    acl, err := h.service.GetByID(uint(id))
    if err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, acl)
}

New Code:

func (h *AccessListHandler) Get(c *gin.Context) {
    acl, err := h.resolveAccessList(c.Param("id"))
    if err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID or UUID"})
        return
    }

    c.JSON(http.StatusOK, acl)
}

Task 1.3: Update Update Handler

File: access_list_handler.go Location: Lines 75-101 (Update method)

Current Code:

func (h *AccessListHandler) Update(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
        return
    }

    var updates models.AccessList
    if err := c.ShouldBindJSON(&updates); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    if err := h.service.Update(uint(id), &updates); err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // Fetch updated record
    acl, _ := h.service.GetByID(uint(id))
    c.JSON(http.StatusOK, acl)
}

New Code:

func (h *AccessListHandler) Update(c *gin.Context) {
    // Resolve access list first to get the internal ID
    acl, err := h.resolveAccessList(c.Param("id"))
    if err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID or UUID"})
        return
    }

    var updates models.AccessList
    if err := c.ShouldBindJSON(&updates); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    if err := h.service.Update(acl.ID, &updates); err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // Fetch updated record
    updatedAcl, _ := h.service.GetByID(acl.ID)
    c.JSON(http.StatusOK, updatedAcl)
}

Task 1.4: Update Delete Handler

File: access_list_handler.go Location: Lines 104-125 (Delete method)

Current Code:

func (h *AccessListHandler) Delete(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
        return
    }

    if err := h.service.Delete(uint(id)); err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        if err == services.ErrAccessListInUse {
            c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})
}

New Code:

func (h *AccessListHandler) Delete(c *gin.Context) {
    // Resolve access list first to get the internal ID
    acl, err := h.resolveAccessList(c.Param("id"))
    if err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID or UUID"})
        return
    }

    if err := h.service.Delete(acl.ID); err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        if err == services.ErrAccessListInUse {
            c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})
}

Task 1.5: Update TestIP Handler

File: access_list_handler.go Location: Lines 128-157 (TestIP method)

Current Code:

func (h *AccessListHandler) TestIP(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
        return
    }

    var req struct {
        IPAddress string `json:"ip_address" binding:"required"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    allowed, reason, err := h.service.TestIP(uint(id), req.IPAddress)
    if err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        if err == services.ErrInvalidIPAddress {
            c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "allowed": allowed,
        "reason":  reason,
    })
}

New Code:

func (h *AccessListHandler) TestIP(c *gin.Context) {
    // Resolve access list first to get the internal ID
    acl, err := h.resolveAccessList(c.Param("id"))
    if err != nil {
        if err == services.ErrAccessListNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
            return
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID or UUID"})
        return
    }

    var req struct {
        IPAddress string `json:"ip_address" binding:"required"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    allowed, reason, err := h.service.TestIP(acl.ID, req.IPAddress)
    if err != nil {
        if err == services.ErrInvalidIPAddress {
            c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "allowed": allowed,
        "reason":  reason,
    })
}

Phase 2: Unit Test Updates (High)

Task 2.1: Add UUID Test Cases

File: access_list_handler_test.go

Add test cases for UUID-based lookups to existing test functions.

TestAccessListHandler_Get (add to tests slice at line 166):

{
    name:       "get by UUID",
    id:         "test-uuid",
    wantStatus: http.StatusOK,
},
{
    name:       "get by invalid format",
    id:         "",
    wantStatus: http.StatusBadRequest,
},

TestAccessListHandler_Update (add to tests slice at line 216):

{
    name: "update by UUID successfully",
    id:   "test-uuid",
    payload: map[string]any{
        "name":     "Updated via UUID",
        "type":     "whitelist",
        "ip_rules": `[]`,
    },
    wantStatus: http.StatusOK,
},

TestAccessListHandler_Delete - Create new ACL with known UUID for delete-by-UUID test.

TestAccessListHandler_TestIP (add to tests slice at line 359):

{
    name:       "test IP by UUID",
    id:         "test-uuid",
    payload:    map[string]string{"ip_address": "192.168.1.100"},
    wantStatus: http.StatusOK,
},

Task 2.2: Add Dedicated resolveAccessList Tests

File: access_list_handler_test.go Location: After TestAccessListHandler_GetTemplates (after line 410)

func TestAccessListHandler_resolveAccessList(t *testing.T) {
    router, db := setupAccessListTestRouter(t)
    _ = router // Needed for handler creation

    handler := NewAccessListHandler(db)

    // Create test ACL with known UUID
    acl := models.AccessList{
        UUID:    "resolve-test-uuid",
        Name:    "Resolve Test ACL",
        Type:    "whitelist",
        Enabled: true,
    }
    db.Create(&acl)

    tests := []struct {
        name      string
        idOrUUID  string
        wantErr   bool
        wantName  string
    }{
        {
            name:     "resolve by numeric ID",
            idOrUUID: "1",
            wantErr:  false,
            wantName: "Resolve Test ACL",
        },
        {
            name:     "resolve by UUID",
            idOrUUID: "resolve-test-uuid",
            wantErr:  false,
            wantName: "Resolve Test ACL",
        },
        {
            name:     "fail with non-existent numeric ID",
            idOrUUID: "9999",
            wantErr:  true,
        },
        {
            name:     "fail with non-existent UUID",
            idOrUUID: "non-existent-uuid",
            wantErr:  true,
        },
        {
            name:     "fail with empty string",
            idOrUUID: "",
            wantErr:  true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := handler.resolveAccessList(tt.idOrUUID)
            if tt.wantErr {
                assert.Error(t, err)
                assert.Nil(t, result)
            } else {
                assert.NoError(t, err)
                assert.NotNil(t, result)
                assert.Equal(t, tt.wantName, result.Name)
            }
        })
    }
}

Phase 3: E2E Test Fixes (High)

Task 3.1: Update acl-enforcement.spec.ts

File: acl-enforcement.spec.ts

Line 138 - Change from lists[0].id to lists[0].uuid:

// Before:
const testResponse = await requestContext.post(
  `/api/v1/access-lists/${lists[0].id}/test`,
  { data: { ip_address: testIp } }
);

// After:
const testResponse = await requestContext.post(
  `/api/v1/access-lists/${lists[0].uuid}/test`,
  { data: { ip_address: testIp } }
);

Line 162 and beyond - Change from createdList.id to createdList.uuid:

// Before:
expect(createdList.id).toBeDefined();
// ...
const testResponse = await requestContext.post(
  `/api/v1/access-lists/${createdList.id}/test`,
  { data: { ip_address: '10.255.255.255' } }
);
// ...
const deleteResponse = await requestContext.delete(
  `/api/v1/access-lists/${createdList.id}`
);

// After:
expect(createdList.uuid).toBeDefined();
// ...
const testResponse = await requestContext.post(
  `/api/v1/access-lists/${createdList.uuid}/test`,
  { data: { ip_address: '10.255.255.255' } }
);
// ...
const deleteResponse = await requestContext.delete(
  `/api/v1/access-lists/${createdList.uuid}`
);

Validation Checklist

Unit Tests

  • All existing tests pass with numeric IDs (backward compatibility)
  • New UUID-based test cases pass
  • resolveAccessList helper tests pass
  • Coverage remains above 85%

E2E Tests

  • should test IP against access list passes (line 138)
  • should show correct error response format passes (line 162)
  • All other ACL enforcement tests remain green

Manual Verification

# Create access list
curl -X POST http://localhost:8080/api/v1/access-lists \
  -H "Content-Type: application/json" \
  -d '{"name":"Test ACL","type":"whitelist","enabled":true}'
# Response: {"uuid":"abc-123-def", "name":"Test ACL", ...}

# Get by UUID (new)
curl http://localhost:8080/api/v1/access-lists/abc-123-def
# Should return the access list

# Get by numeric ID (backward compat - requires direct DB access)
curl http://localhost:8080/api/v1/access-lists/1
# Should return the access list

# Test IP by UUID
curl -X POST http://localhost:8080/api/v1/access-lists/abc-123-def/test \
  -H "Content-Type: application/json" \
  -d '{"ip_address":"192.168.1.1"}'
# Should return {"allowed": true/false, "reason": "..."}

Dependencies

Component Dependency Risk
Handler Service GetByUUID() Already exists (line 115)
Handler Service GetByID() Already exists (line 103)
Handler strconv.ParseUint Standard library
Tests Test fixtures UUID must be set explicitly

Risk Assessment

Risk Likelihood Impact Mitigation
Performance (UUID lookup) Low Low UUID is indexed, same performance as ID
Numeric string as UUID Low Medium Check numeric first, then UUID
Breaking changes Low High Backward compatible - numeric IDs still work
Test fixture setup Medium Low Ensure test ACLs have known UUIDs

Estimated Effort

Task Complexity Duration
Add resolveAccessList helper Simple 10 min
Update 4 handlers Simple 20 min
Update unit tests Medium 30 min
Update E2E tests Simple 10 min
Validation & QA Medium 20 min
Total ~90 min

Acceptance Criteria

  1. All 4 access list handlers accept both numeric ID and UUID
  2. Numeric ID lookup is attempted first (backward compatibility)
  3. E2E tests acl-enforcement.spec.ts pass without modifications to test logic (only changing .id to .uuid)
  4. Unit test coverage remains above 85%
  5. No breaking changes to existing API consumers using numeric IDs