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
Scan to join WeChat group