When building systems that require access control, one common requirement is managing policies that define who can access what. In this post, I’ll walk through the design and implementation of a policy management system in Go, highlighting important design decisions and patterns along the way.
The Initial Design
Let’s start with a basic policy model. Our policies need to track permissions for different paths and SPIFFE IDs:
type PolicyPermission string
const (
PermissionRead PolicyPermission = "read"
PermissionWrite PolicyPermission = "write"
)
type Policy struct {
Id string `json:"id"`
Name string `json:"name"`
SpiffeIdPattern string `json:"spiffe_id_pattern"`
PathPattern string `json:"path_pattern"`
Permissions []PolicyPermission `json:"permissions"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
}
For thread-safe storage, we’ll use Go’s sync.Map
:
var policies sync.Map
The Evolution of the Design
First Iteration: Basic CRUD
Our first attempt might look something like this:
func CreatePolicy(policies *sync.Map, policy Policy) error {
if policy.Id == "" || policy.Name == "" {
return ErrInvalidPolicy
}
if _, exists := policies.Load(policy.Id); exists {
return ErrPolicyExists
}
policies.Store(policy.Id, policy)
return nil
}
However, this design has a few issues:
- It requires clients to generate their own IDs
- It mixes validation with storage logic
- It doesn’t return the created policy
Second Iteration: Adding Request/Response Types
We might be tempted to add request/response types:
type PolicyRequest struct {
Name string
SpiffeIdPattern string
PathPattern string
Permissions []PolicyPermission
CreatedBy string
}
func CreatePolicy(policies *sync.Map, req PolicyRequest) (Policy, error)
But this introduces a new problem: we’re mixing API concerns with our core policy management logic. The policy package should remain focused on managing Policy objects, regardless of how they’re being created or accessed.
Final Design: Clean Separation of Concerns
The better approach is to keep the policy package focused on managing Policy objects and handle request/response mapping at the API layer:
// In the policy package
func CreatePolicy(policies *sync.Map, policy Policy) (Policy, error) {
if policy.Name == "" {
return Policy{}, ErrInvalidPolicy
}
policy.Id = uuid.New().String()
if policy.CreatedAt.IsZero() {
policy.CreatedAt = time.Now()
}
policies.Store(policy.Id, policy)
return policy, nil
}
Then in your HTTP handler or API layer:
// In your API package
func (h *Handler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
var req CreatePolicyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
policy := Policy{
Name: req.Name,
SpiffeIdPattern: req.SpiffeIdPattern,
PathPattern: req.PathPattern,
Permissions: req.Permissions,
CreatedBy: getUserFromContext(r.Context()),
}
createdPolicy, err := CreatePolicy(h.policies, policy)
if err != nil {
// Handle error
return
}
json.NewEncoder(w).Encode(createdPolicy)
}
Key Design Principles
Separation of Concerns: Keep the core policy logic separate from API concerns. The policy package shouldn’t know about HTTP requests or JSON serialization.
Single Responsibility: Each component should have one job:
- Policy package: Manage policy objects
- API layer: Handle HTTP requests/responses
- Validation layer: Validate inputs
Interface Segregation: The policy package exposes simple operations (Create, Read, Update, Delete) that can be composed into more complex operations at higher levels.
Immutability: Operations that modify policies return new Policy objects rather than modifying existing ones.
Benefits of This Design
- Testability: Core policy logic can be tested without HTTP concerns
- Reusability: The policy package can be used with different interfaces (HTTP, gRPC, CLI)
- Maintainability: Changes to API formats don’t require changes to core logic
- Flexibility: Easy to add new features or change implementation details
Conclusion
When designing systems, it’s tempting to mix concerns for convenience. However, keeping clear boundaries between different layers of your application leads to more maintainable and flexible code. In our policy management system, separating the core policy logic from API concerns gives us a more robust and reusable solution.
The final implementation allows for easy extension and modification while maintaining clean separation between the different concerns in the system. This makes it easier to test, maintain, and evolve the system as requirements change.
Remember: Good design isn’t about getting it perfect the first time - it’s about making it easy to change as you learn more about your requirements.