Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,6 @@ func main() {
log.Fatalf("Failed to initialize default permission roles: %v", err)
}

// Initialize Token Service for API token management
tokenService := service.NewTokenService()

// Initialize Score Engine Service for automatic risk score calculation
scoreEngineService := service.NewScoreEngineService(database.DB)
log.Println("Score Engine: Service initialized with default configuration")
Expand Down Expand Up @@ -592,8 +589,9 @@ func main() {
tokenManager.SetOrgSessionResolver(resolveSessionForOrg)

// L5 — Personal Access Tokens. DB-backed service (survives restarts, scoped),
// its auth middleware, and a management handler. The same resolveSession gives a
// PAT the owner's tenant + permissions (narrowed to the token's scopes).
// its auth middleware, and a management handler. resolveSessionForOrg gives a
// PAT its owner's permissions in the token's own tenant (narrowed to the
// token's scopes), and refuses once the owner is no longer a member there.
patService := coreauth.NewPersonalAccessTokenService(repository.NewGormPersonalAccessTokenRepository(database.DB))

// L7 — full-fidelity auth audit trail (auth_audit_logs: IP, UA, geo, device
Expand Down Expand Up @@ -977,7 +975,7 @@ func main() {
// L5 — PAT authentication runs BEFORE the JWT gate: it authenticates PAT-shaped
// bearers and is a no-op for JWTs (which the RS256 middleware then handles). The
// JWT middleware skips when a PAT already authenticated the request.
api.Use(middleware.PATMiddleware(patService, resolveSession))
api.Use(middleware.PATMiddleware(patService, resolveSessionForOrg))
protected := api.Use(middleware.Protected(rsaKeys, jtiBlacklistChecker))

// Response-cache invalidation (#337). Mounted here, right after the gate that
Expand Down Expand Up @@ -2101,20 +2099,6 @@ func main() {
protected.Get("/audit-logs/user/:user_id", adminRole, auditHandler.GetUserAuditLogs)
protected.Get("/audit-logs/action/:action", adminRole, auditHandler.GetAuditLogsByAction)

// --- API Token Management (Protected routes) ---
// Tokens can be managed by any authenticated user for their own tokens
tokenHandler := handlers.NewTokenHandler(tokenService)

// API tokens are personal: every verb below loads the token and refuses when
// token.UserID is not the caller. The session is the authorization (#529).
protected.Post("/tokens", tokenHandler.CreateToken)
protected.Get("/tokens", tokenHandler.ListTokens)
protected.Get("/tokens/:id", tokenHandler.GetToken)
protected.Put("/tokens/:id", tokenHandler.UpdateToken)
protected.Post("/tokens/:id/revoke", tokenHandler.RevokeToken)
protected.Post("/tokens/:id/rotate", tokenHandler.RotateToken)
protected.Delete("/tokens/:id", tokenHandler.DeleteToken)

// --- Custom Fields Management (Protected routes) ---
customFieldHandler := handlers.NewCustomFieldHandler()
// Reading the field definitions is what every risk and asset FORM does, so
Expand Down
3 changes: 0 additions & 3 deletions backend/cmd/server/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ import (
// the issue that owns it. Adding a name requires an issue number (enforced
// below); an entry must go as soon as its table is built (also enforced).
var knownMissingTables = map[string]string{
// Stored in an in-process map by service.TokenService; the table is never
// read or written. The feature itself is broken, see the issue.
"APIToken": "#709",
// The marketplace is not built (ROADMAP 14.18): its models carry no gorm
// tags and cannot be migrated as they stand, and its routes are not
// mounted (main.go), so no request reaches these tables.
Expand Down
83 changes: 50 additions & 33 deletions backend/internal/auth/pat.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import (
"github.com/opendefender/openrisk/internal/infrastructure/repository"
)

// PATValuePrefix opens every token value this service mints, so a leaked token
// is recognisable on sight and by secret scanners: "orsk_<8-hex>_<64-hex>".
// Tokens minted before #782 have no such prefix and still validate.
const PATValuePrefix = "orsk_"

// PersonalAccessTokenService handles PAT operations
type PersonalAccessTokenService struct {
repo repository.PersonalAccessTokenRepository
Expand Down Expand Up @@ -55,8 +60,13 @@ func (s *PersonalAccessTokenService) generateTokenPrefix() (string, error) {
return hex.EncodeToString(bytes)[:8], nil
}

// CreateToken creates a new personal access token
func (s *PersonalAccessTokenService) CreateToken(ctx context.Context, userID uuid.UUID, name, description string, scopes []string, expiresAt *time.Time) (*domain.PersonalAccessToken, string, error) {
// CreateToken creates a personal access token for userID in tenantID. The token
// can only ever act in that tenant. The raw value is returned once and is never
// stored; only its SHA-256 hash is.
func (s *PersonalAccessTokenService) CreateToken(ctx context.Context, userID, tenantID uuid.UUID, name, description string, scopes []string, expiresAt *time.Time) (*domain.PersonalAccessToken, string, error) {
if userID == uuid.Nil || tenantID == uuid.Nil {
return nil, "", fmt.Errorf("%w: token owner and tenant are required", domain.ErrValidation)
}
// Generate secure token
token, err := s.generateSecureToken()
if err != nil {
Expand All @@ -78,6 +88,7 @@ func (s *PersonalAccessTokenService) CreateToken(ctx context.Context, userID uui

pat := &domain.PersonalAccessToken{
UserID: userID,
TenantID: tenantID,
Name: name,
Description: description,
TokenHash: tokenHash,
Expand All @@ -93,24 +104,25 @@ func (s *PersonalAccessTokenService) CreateToken(ctx context.Context, userID uui
return nil, "", fmt.Errorf("failed to save token: %w", err)
}

// Return the GitHub-style "<prefix>_<secret>" value. ValidateToken splits on
// "_" and requires an 8-char prefix; previously CreateToken returned the bare
// secret with no prefix, so every token this service minted failed validation.
return pat, tokenPrefix + "_" + token, nil
return pat, PATValuePrefix + tokenPrefix + "_" + token, nil
}

// ValidateToken validates a token and returns the PAT if valid
func (s *PersonalAccessTokenService) ValidateToken(ctx context.Context, token string) (*domain.PersonalAccessToken, error) {
// Extract prefix and validate format
parts := strings.Split(token, "_")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid token format")
// SplitPATValue parses "orsk_<8-hex>_<secret>" or the pre-#782 form
// "<8-hex>_<secret>" into its lookup prefix and secret. ok is false for
// anything else, including a JWT.
func SplitPATValue(raw string) (prefix, secret string, ok bool) {
parts := strings.Split(strings.TrimPrefix(raw, PATValuePrefix), "_")
if len(parts) != 2 || len(parts[0]) != 8 || parts[1] == "" {
return "", "", false
}
prefix := parts[0]
tokenValue := parts[1]
return parts[0], parts[1], true
}

if len(prefix) != 8 {
return nil, fmt.Errorf("invalid token prefix")
// ValidateToken validates a token and returns the PAT if valid
func (s *PersonalAccessTokenService) ValidateToken(ctx context.Context, token string) (*domain.PersonalAccessToken, error) {
prefix, tokenValue, ok := SplitPATValue(token)
if !ok {
return nil, fmt.Errorf("%w: invalid token format", domain.ErrUnauthorized)
}

// Hash the token value
Expand All @@ -119,21 +131,26 @@ func (s *PersonalAccessTokenService) ValidateToken(ctx context.Context, token st
// Find token by hash
pat, err := s.repo.GetByTokenHash(ctx, tokenHash)
if err != nil {
return nil, fmt.Errorf("token not found")
return nil, fmt.Errorf("%w: unknown token", domain.ErrUnauthorized)
}

// Check if token belongs to the correct prefix
if pat.TokenPrefix != prefix {
return nil, fmt.Errorf("invalid token")
return nil, fmt.Errorf("%w: invalid token", domain.ErrUnauthorized)
}

// Check if token is expired
if pat.IsExpired() {
return nil, fmt.Errorf("token expired")
return nil, fmt.Errorf("%w: token expired", domain.ErrUnauthorized)
}

// A row from before #782 that the backfill could not attribute is unusable.
if pat.TenantID == uuid.Nil {
return nil, fmt.Errorf("%w: token has no tenant", domain.ErrUnauthorized)
}

// Update last used timestamp
if err := s.repo.UpdateLastUsed(ctx, pat.ID); err != nil {
if err := s.repo.UpdateLastUsed(ctx, pat.TenantID, pat.ID); err != nil {
// Log error but don't fail validation
fmt.Printf("Failed to update last used timestamp: %v\n", err)
}
Expand Down Expand Up @@ -175,22 +192,22 @@ func (s *PersonalAccessTokenService) HasScope(pat *domain.PersonalAccessToken, r
return false
}

// ListUserTokens gets all tokens for a user
func (s *PersonalAccessTokenService) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]*domain.PersonalAccessToken, error) {
return s.repo.GetByUserID(ctx, userID)
// ListUserTokens lists the tokens userID minted in tenantID. Tokens the same
// person minted in another organization are not shown.
func (s *PersonalAccessTokenService) ListUserTokens(ctx context.Context, tenantID, userID uuid.UUID) ([]*domain.PersonalAccessToken, error) {
return s.repo.ListByOwner(ctx, tenantID, userID)
}

// RevokeToken revokes a token
func (s *PersonalAccessTokenService) RevokeToken(ctx context.Context, tokenID uuid.UUID, userID uuid.UUID) error {
// First verify the token belongs to the user
pat, err := s.repo.GetByID(ctx, tokenID)
// RevokeToken deletes a token userID owns in tenantID. Someone else's token, or
// the caller's own token from another organization, is ErrNotFound: whether it
// exists is not the caller's business.
func (s *PersonalAccessTokenService) RevokeToken(ctx context.Context, tenantID, tokenID, userID uuid.UUID) error {
removed, err := s.repo.DeleteByOwner(ctx, tenantID, userID, tokenID)
if err != nil {
return fmt.Errorf("token not found")
return err
}

if pat.UserID != userID {
return fmt.Errorf("unauthorized")
if !removed {
return domain.ErrNotFound
}

return s.repo.Delete(ctx, tokenID)
return nil
}
Loading
Loading