| name | security-rbac-auth |
| description | Implement authentication, authorization, and security controls. Use for JWT handling, API key management, RBAC, OAuth integration, and security policies. Triggers on "authentication", "authorization", "JWT", "API key", "RBAC", "OAuth", "security", "permissions", or when implementing spec/006-security-governance.md. |
Security, RBAC, and Authentication
Overview
Implement comprehensive security for AgentStack including authentication (JWT, API Keys, OAuth), authorization (RBAC), and security controls (encryption, audit logging, secret management).
Security Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Security Stack │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ PERIMETER │ │
│ │ TLS 1.3 │ Rate Limiting │ CORS │ WAF │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AUTHENTICATION │ │
│ │ JWT (RS256) │ API Keys │ OAuth 2.0 │ OIDC │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AUTHORIZATION │ │
│ │ RBAC │ Project Scoping │ Resource Policies │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DATA PROTECTION │ │
│ │ Encryption at Rest │ Secrets │ PII Masking │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
JWT Authentication
JWT Structure
// internal/auth/jwt/claims.go
package jwt
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
jwt.RegisteredClaims
// User identity
UserID string `json:"sub"`
Email string `json:"email"`
// Organization context
OrgID string `json:"org_id"`
ProjectID string `json:"project_id,omitempty"`
TeamIDs []string `json:"teams,omitempty"`
// Permissions
Permissions []string `json:"permissions"`
Roles []string `json:"roles"`
}
func NewClaims(user *User, org *Organization) *Claims {
now := time.Now()
return &Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://auth.agentstack.io",
Audience: jwt.ClaimStrings{"https://api.agentstack.io"},
Subject: user.ID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
NotBefore: jwt.NewNumericDate(now),
ID: uuid.NewString(),
},
UserID: user.ID,
Email: user.Email,
OrgID: org.ID,
TeamIDs: user.TeamIDs,
Permissions: user.GetPermissions(org),
Roles: user.GetRoles(org),
}
}
JWT Service
// internal/auth/jwt/service.go
package jwt
import (
"crypto/rsa"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Service struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
keyID string
}
func NewService(privateKeyPEM, publicKeyPEM []byte, keyID string) (*Service, error) {
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
publicKey, err := jwt.ParseRSAPublicKeyFromPEM(publicKeyPEM)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
return &Service{
privateKey: privateKey,
publicKey: publicKey,
keyID: keyID,
}, nil
}
func (s *Service) GenerateToken(claims *Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = s.keyID
return token.SignedString(s.privateKey)
}
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.publicKey, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
// GenerateRefreshToken creates a long-lived refresh token
func (s *Service) GenerateRefreshToken(userID string) (string, error) {
claims := &jwt.RegisteredClaims{
Subject: userID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.NewString(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(s.privateKey)
}
Auth Middleware
// internal/api/middleware/auth.go
package middleware
import (
"context"
"strings"
"github.com/gofiber/fiber/v2"
)
type contextKey string
const (
UserContextKey contextKey = "user"
ClaimsContextKey contextKey = "claims"
)
func JWTAuth(jwtService *jwt.Service) fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(401).JSON(fiber.Map{
"error": "missing authorization header",
})
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return c.Status(401).JSON(fiber.Map{
"error": "invalid authorization format",
})
}
claims, err := jwtService.ValidateToken(parts[1])
if err != nil {
return c.Status(401).JSON(fiber.Map{
"error": "invalid token",
})
}
// Add claims to context
c.Locals(string(ClaimsContextKey), claims)
return c.Next()
}
}
func ClaimsFromContext(c *fiber.Ctx) *jwt.Claims {
claims, _ := c.Locals(string(ClaimsContextKey)).(*jwt.Claims)
return claims
}
API Key Authentication
API Key Structure
// internal/auth/apikey/key.go
package apikey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
)
const (
Prefix = "ask_" // AgentStack Key
KeyLength = 32 // bytes
)
type APIKey struct {
ID string
Prefix string // "ask_"
Hash string // SHA-256 hash of full key
ProjectID string
OrgID string
Name string
Permissions []string
LastUsedAt *time.Time
ExpiresAt *time.Time
CreatedAt time.Time
}
// Generate creates a new API key and returns the full key (shown once)
func Generate(projectID, orgID, name string, permissions []string) (*APIKey, string, error) {
// Generate random bytes
randomBytes := make([]byte, KeyLength)
if _, err := rand.Read(randomBytes); err != nil {
return nil, "", err
}
// Encode to base62-like string
keyBody := base64.RawURLEncoding.EncodeToString(randomBytes)
fullKey := Prefix + keyBody
// Hash for storage (never store plaintext)
hash := sha256.Sum256([]byte(fullKey))
hashStr := fmt.Sprintf("%x", hash)
key := &APIKey{
ID: uuid.NewString(),
Prefix: fullKey[:len(Prefix)+4], // Store prefix for identification
Hash: hashStr,
ProjectID: projectID,
OrgID: orgID,
Name: name,
Permissions: permissions,
CreatedAt: time.Now(),
}
return key, fullKey, nil
}
// Verify checks if a provided key matches the stored hash
func (k *APIKey) Verify(providedKey string) bool {
hash := sha256.Sum256([]byte(providedKey))
return fmt.Sprintf("%x", hash) == k.Hash
}
API Key Middleware
// internal/api/middleware/apikey.go
package middleware
func APIKeyAuth(keyRepo apikey.Repository) fiber.Handler {
return func(c *fiber.Ctx) error {
// Check X-API-Key header
apiKey := c.Get("X-API-Key")
if apiKey == "" {
// Also check Authorization header
authHeader := c.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ask_") {
apiKey = strings.TrimPrefix(authHeader, "Bearer ")
}
}
if apiKey == "" {
return c.Status(401).JSON(fiber.Map{
"error": "missing API key",
})
}
// Validate prefix
if !strings.HasPrefix(apiKey, apikey.Prefix) {
return c.Status(401).JSON(fiber.Map{
"error": "invalid API key format",
})
}
// Look up key by prefix (first 8 chars)
prefix := apiKey[:len(apikey.Prefix)+4]
key, err := keyRepo.FindByPrefix(c.Context(), prefix)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"error": "invalid API key",
})
}
// Verify full key
if !key.Verify(apiKey) {
return c.Status(401).JSON(fiber.Map{
"error": "invalid API key",
})
}
// Check expiration
if key.ExpiresAt != nil && key.ExpiresAt.Before(time.Now()) {
return c.Status(401).JSON(fiber.Map{
"error": "API key expired",
})
}
// Update last used (async)
go keyRepo.UpdateLastUsed(context.Background(), key.ID)
// Set context
c.Locals("api_key", key)
c.Locals("org_id", key.OrgID)
c.Locals("project_id", key.ProjectID)
c.Locals("permissions", key.Permissions)
return c.Next()
}
}
RBAC Authorization
Role Definitions
// internal/auth/rbac/roles.go
package rbac
type Role string
const (
// Platform level
RolePlatformAdmin Role = "platform:admin"
// Organization level
RoleOrgOwner Role = "org:owner"
RoleOrgAdmin Role = "org:admin"
RoleOrgMember Role = "org:member"
// Project level
RoleProjectAdmin Role = "project:admin"
RoleProjectDeveloper Role = "project:developer"
RoleProjectViewer Role = "project:viewer"
)
type Permission string
const (
// Agent permissions
PermAgentsRead Permission = "agents:read"
PermAgentsWrite Permission = "agents:write"
PermAgentsDelete Permission = "agents:delete"
PermAgentsDeploy Permission = "agents:deploy"
// Secret permissions
PermSecretsRead Permission = "secrets:read"
PermSecretsWrite Permission = "secrets:write"
// Project permissions
PermProjectRead Permission = "project:read"
PermProjectWrite Permission = "project:write"
PermProjectDelete Permission = "project:delete"
// Team permissions
PermTeamManage Permission = "team:manage"
// Billing
PermBillingManage Permission = "billing:manage"
)
// RolePermissions maps roles to their permissions
var RolePermissions = map[Role][]Permission{
RolePlatformAdmin: {
PermAgentsRead, PermAgentsWrite, PermAgentsDelete, PermAgentsDeploy,
PermSecretsRead, PermSecretsWrite,
PermProjectRead, PermProjectWrite, PermProjectDelete,
PermTeamManage, PermBillingManage,
},
RoleOrgOwner: {
PermAgentsRead, PermAgentsWrite, PermAgentsDelete, PermAgentsDeploy,
PermSecretsRead, PermSecretsWrite,
PermProjectRead, PermProjectWrite, PermProjectDelete,
PermTeamManage, PermBillingManage,
},
RoleOrgAdmin: {
PermAgentsRead, PermAgentsWrite, PermAgentsDelete, PermAgentsDeploy,
PermSecretsRead, PermSecretsWrite,
PermProjectRead, PermProjectWrite,
PermTeamManage,
},
RoleProjectDeveloper: {
PermAgentsRead, PermAgentsWrite, PermAgentsDeploy,
PermSecretsRead,
PermProjectRead,
},
RoleProjectViewer: {
PermAgentsRead,
PermProjectRead,
},
}
Authorization Middleware
// internal/api/middleware/authorize.go
package middleware
import (
"github.com/gofiber/fiber/v2"
)
// RequirePermission checks if user has the required permission
func RequirePermission(permission rbac.Permission) fiber.Handler {
return func(c *fiber.Ctx) error {
claims := ClaimsFromContext(c)
if claims == nil {
// Check API key permissions
perms, _ := c.Locals("permissions").([]string)
if !hasPermission(perms, string(permission)) {
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
"message": "insufficient permissions",
})
}
return c.Next()
}
// Check JWT permissions
if !hasPermission(claims.Permissions, string(permission)) {
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
"message": "insufficient permissions",
})
}
return c.Next()
}
}
// RequireRole checks if user has one of the required roles
func RequireRole(roles ...rbac.Role) fiber.Handler {
return func(c *fiber.Ctx) error {
claims := ClaimsFromContext(c)
if claims == nil {
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
})
}
for _, required := range roles {
for _, userRole := range claims.Roles {
if userRole == string(required) {
return c.Next()
}
}
}
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
"message": "insufficient role",
})
}
}
func hasPermission(perms []string, required string) bool {
for _, p := range perms {
if p == required || p == "*" {
return true
}
}
return false
}
Resource-Level Authorization
// internal/auth/rbac/policy.go
package rbac
import "context"
type Policy interface {
CanAccess(ctx context.Context, subject Subject, resource Resource, action Action) (bool, error)
}
type Subject struct {
UserID string
OrgID string
ProjectID string
Roles []Role
Permissions []Permission
}
type Resource struct {
Type string // "agent", "secret", "project"
ID string
OwnerOrg string
OwnerProj string
}
type Action string
const (
ActionRead Action = "read"
ActionWrite Action = "write"
ActionDelete Action = "delete"
ActionDeploy Action = "deploy"
)
type DefaultPolicy struct {
repo PolicyRepository
}
func (p *DefaultPolicy) CanAccess(ctx context.Context, subject Subject, resource Resource, action Action) (bool, error) {
// Platform admin can do anything
if hasRole(subject.Roles, RolePlatformAdmin) {
return true, nil
}
// Must be in same org
if subject.OrgID != resource.OwnerOrg {
return false, nil
}
// Check project-level access
if resource.OwnerProj != "" && subject.ProjectID != resource.OwnerProj {
// Check if user has cross-project permissions
if !hasRole(subject.Roles, RoleOrgAdmin, RoleOrgOwner) {
return false, nil
}
}
// Check action permission
requiredPerm := resourceActionToPermission(resource.Type, action)
return hasPermission(subject.Permissions, requiredPerm), nil
}
OAuth 2.0 Integration
// internal/auth/oauth/provider.go
package oauth
import (
"context"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/github"
)
type Provider interface {
AuthURL(state string) string
Exchange(ctx context.Context, code string) (*oauth2.Token, error)
GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error)
}
type UserInfo struct {
ID string
Email string
Name string
Avatar string
Provider string
}
type GoogleProvider struct {
config *oauth2.Config
}
func NewGoogleProvider(clientID, clientSecret, redirectURL string) *GoogleProvider {
return &GoogleProvider{
config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
},
}
}
func (p *GoogleProvider) AuthURL(state string) string {
return p.config.AuthCodeURL(state)
}
func (p *GoogleProvider) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
return p.config.Exchange(ctx, code)
}
func (p *GoogleProvider) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
client := p.config.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var info struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
}
return &UserInfo{
ID: info.ID,
Email: info.Email,
Name: info.Name,
Avatar: info.Picture,
Provider: "google",
}, nil
}
Secret Management
// internal/secrets/service.go
package secrets
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
)
type Service struct {
key []byte // 32 bytes for AES-256
repo Repository
}
func NewService(encryptionKey string, repo Repository) *Service {
return &Service{
key: []byte(encryptionKey),
repo: repo,
}
}
func (s *Service) Create(ctx context.Context, projectID, name, value string) (*Secret, error) {
// Encrypt value
encrypted, err := s.encrypt(value)
if err != nil {
return nil, err
}
secret := &Secret{
ID: uuid.NewString(),
ProjectID: projectID,
Name: name,
Value: encrypted,
CreatedAt: time.Now(),
}
return s.repo.Create(ctx, secret)
}
func (s *Service) Get(ctx context.Context, projectID, name string) (string, error) {
secret, err := s.repo.FindByName(ctx, projectID, name)
if err != nil {
return "", err
}
return s.decrypt(secret.Value)
}
func (s *Service) encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (s *Service) decrypt(ciphertext string) (string, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(s.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
Audit Logging
// internal/audit/logger.go
package audit
import (
"context"
"encoding/json"
"time"
)
type Event struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Actor Actor `json:"actor"`
Action string `json:"action"`
Resource Resource `json:"resource"`
Result string `json:"result"` // success, failure
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Details map[string]interface{} `json:"details,omitempty"`
}
type Actor struct {
Type string `json:"type"` // user, api_key, service
ID string `json:"id"`
Email string `json:"email,omitempty"`
OrgID string `json:"org_id"`
}
type Resource struct {
Type string `json:"type"`
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
}
type Logger struct {
writer EventWriter
}
func (l *Logger) Log(ctx context.Context, event Event) error {
event.ID = uuid.NewString()
event.Timestamp = time.Now().UTC()
return l.writer.Write(ctx, event)
}
// LogAction is a convenience method
func (l *Logger) LogAction(ctx context.Context, action string, resource Resource, result string) {
actor := ActorFromContext(ctx)
event := Event{
Actor: actor,
Action: action,
Resource: resource,
Result: result,
IP: IPFromContext(ctx),
}
l.Log(ctx, event)
}
Rate Limiting
// internal/api/middleware/ratelimit.go
package middleware
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
type RateLimiter struct {
redis *redis.Client
limit int
window time.Duration
}
func NewRateLimiter(redis *redis.Client, limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
redis: redis,
limit: limit,
window: window,
}
}
func (rl *RateLimiter) Middleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get identifier (API key or user ID)
key := rl.getKey(c)
// Increment counter
count, err := rl.redis.Incr(c.Context(), key).Result()
if err != nil {
// Allow on Redis failure (fail open)
return c.Next()
}
// Set expiry on first request
if count == 1 {
rl.redis.Expire(c.Context(), key, rl.window)
}
// Check limit
if int(count) > rl.limit {
ttl, _ := rl.redis.TTL(c.Context(), key).Result()
c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
c.Set("X-RateLimit-Remaining", "0")
c.Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(ttl).Unix()))
c.Set("Retry-After", fmt.Sprintf("%d", int(ttl.Seconds())))
return c.Status(429).JSON(fiber.Map{
"error": "rate_limit_exceeded",
"message": "Too many requests",
})
}
// Set headers
c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
c.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rl.limit-int(count)))
return c.Next()
}
}
func (rl *RateLimiter) getKey(c *fiber.Ctx) string {
// Prefer API key, then user ID, then IP
if key, ok := c.Locals("api_key").(*apikey.APIKey); ok {
return fmt.Sprintf("ratelimit:%s", key.ID)
}
if claims := ClaimsFromContext(c); claims != nil {
return fmt.Sprintf("ratelimit:user:%s", claims.UserID)
}
return fmt.Sprintf("ratelimit:ip:%s", c.IP())
}
Resources
references/oauth-providers.md- OAuth provider configurationsreferences/security-headers.md- HTTP security headers