| 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
package jwt
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"sub"`
Email string `json:"email"`
OrgID string `json:"org_id"`
ProjectID string `json:"project_id,omitempty"`
TeamIDs []string `json:"teams,omitempty"`
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
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
}
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
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",
})
}
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
package apikey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
)
const (
Prefix = "ask_"
KeyLength = 32
)
type APIKey struct {
ID string
Prefix string
Hash string
ProjectID string
OrgID string
Name string
Permissions []string
LastUsedAt *time.Time
ExpiresAt *time.Time
CreatedAt time.Time
}
func Generate(projectID, orgID, name string, permissions []string) (*APIKey, string, error) {
randomBytes := make([]byte, KeyLength)
if _, err := rand.Read(randomBytes); err != nil {
return nil, "", err
}
keyBody := base64.RawURLEncoding.EncodeToString(randomBytes)
fullKey := Prefix + keyBody
hash := sha256.Sum256([]byte(fullKey))
hashStr := fmt.Sprintf("%x", hash)
key := &APIKey{
ID: uuid.NewString(),
Prefix: fullKey[:len(Prefix)+4],
Hash: hashStr,
ProjectID: projectID,
OrgID: orgID,
Name: name,
Permissions: permissions,
CreatedAt: time.Now(),
}
return key, fullKey, nil
}
func (k *APIKey) Verify(providedKey string) bool {
hash := sha256.Sum256([]byte(providedKey))
return fmt.Sprintf("%x", hash) == k.Hash
}
API Key Middleware
package middleware
func APIKeyAuth(keyRepo apikey.Repository) fiber.Handler {
return func(c *fiber.Ctx) error {
apiKey := c.Get("X-API-Key")
if apiKey == "" {
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",
})
}
if !strings.HasPrefix(apiKey, apikey.Prefix) {
return c.Status(401).JSON(fiber.Map{
"error": "invalid API key format",
})
}
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",
})
}
if !key.Verify(apiKey) {
return c.Status(401).JSON(fiber.Map{
"error": "invalid API key",
})
}
if key.ExpiresAt != nil && key.ExpiresAt.Before(time.Now()) {
return c.Status(401).JSON(fiber.Map{
"error": "API key expired",
})
}
go keyRepo.UpdateLastUsed(context.Background(), key.ID)
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
package rbac
type Role string
const (
RolePlatformAdmin Role = "platform:admin"
RoleOrgOwner Role = "org:owner"
RoleOrgAdmin Role = "org:admin"
RoleOrgMember Role = "org:member"
RoleProjectAdmin Role = "project:admin"
RoleProjectDeveloper Role = "project:developer"
RoleProjectViewer Role = "project:viewer"
)
type Permission string
const (
PermAgentsRead Permission = "agents:read"
PermAgentsWrite Permission = "agents:write"
PermAgentsDelete Permission = "agents:delete"
PermAgentsDeploy Permission = "agents:deploy"
PermSecretsRead Permission = "secrets:read"
PermSecretsWrite Permission = "secrets:write"
PermProjectRead Permission = "project:read"
PermProjectWrite Permission = "project:write"
PermProjectDelete Permission = "project:delete"
PermTeamManage Permission = "team:manage"
PermBillingManage Permission = "billing:manage"
)
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
package middleware
import (
"github.com/gofiber/fiber/v2"
)
func RequirePermission(permission rbac.Permission) fiber.Handler {
return func(c *fiber.Ctx) error {
claims := ClaimsFromContext(c)
if claims == nil {
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()
}
if !hasPermission(claims.Permissions, string(permission)) {
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
"message": "insufficient permissions",
})
}
return c.Next()
}
}
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
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
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) {
if hasRole(subject.Roles, RolePlatformAdmin) {
return true, nil
}
if subject.OrgID != resource.OwnerOrg {
return false, nil
}
if resource.OwnerProj != "" && subject.ProjectID != resource.OwnerProj {
if !hasRole(subject.Roles, RoleOrgAdmin, RoleOrgOwner) {
return false, nil
}
}
requiredPerm := resourceActionToPermission(resource.Type, action)
return hasPermission(subject.Permissions, requiredPerm), nil
}
OAuth 2.0 Integration
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
package secrets
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
)
type Service struct {
key []byte
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) {
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
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"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Details map[string]interface{} `json:"details,omitempty"`
}
type Actor struct {
Type string `json:"type"`
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)
}
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
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 {
key := rl.getKey(c)
count, err := rl.redis.Incr(c.Context(), key).Result()
if err != nil {
return c.Next()
}
if count == 1 {
rl.redis.Expire(c.Context(), key, rl.window)
}
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",
})
}
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 {
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 configurations
references/security-headers.md - HTTP security headers