Ctrl K
ComunidadGuías…

internal/pat/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package pat

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/stump-wtf/cairn/internal/errs"
	"github.com/stump-wtf/cairn/internal/id"
	"github.com/stump-wtf/cairn/internal/user"
)

// maxNameLength bounds the caller-supplied label so a pathological value
// cannot bloat the settings listing.
const maxNameLength = 128

// Service is the personal-access-token core over Postgres. It shares the
// same pool the artifact store and other core services use (ADR-0012: one
// binary, one database) and every query is parameterized (SPEC-0007 REQ
// "Database Operation Standards").
type Service struct {
	pool *pgxpool.Pool
	now  func() time.Time
}

// NewService builds the PAT core over pool.
func NewService(pool *pgxpool.Pool) *Service {
	return &Service{pool: pool, now: time.Now}
}

// Create mints a new personal access token owned by userID. scopes MUST
// already be validated (ParseScope) by the caller; an empty/nil scopes slice
// is rejected — a token that can do nothing is a caller mistake, not a valid
// grant. The plaintext secret is returned exactly once; only its SHA-256
// digest is persisted (SPEC-0007 REQ "Database Operation Standards").
func (s *Service) Create(ctx context.Context, userID, name string, scopes []string, isAgent bool) (secret string, tok *Token, err error) {
	if !user.ValidID(userID) {
		return "", nil, errs.Validationf("tokens: owner is required")
	}
	if name == "" {
		return "", nil, errs.Validationf("tokens: name is required")
	}
	if len(name) > maxNameLength {
		return "", nil, errs.Validationf("tokens: name must be %d characters or fewer", maxNameLength)
	}
	if len(scopes) == 0 {
		return "", nil, errs.Validationf("tokens: at least one scope is required")
	}
	tokenID, err := id.NewLength(20)
	if err != nil {
		return "", nil, fmt.Errorf("pat: mint token id: %w", err)
	}
	tokenID = "pat-" + tokenID
	secret, err = newSecret()
	if err != nil {
		return "", nil, err
	}
	now := s.now().UTC()
	if _, err := s.pool.Exec(ctx, `
		INSERT INTO personal_access_tokens (id, user_id, name, token_hash, scope, is_agent, created_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7)`,
		tokenID, userID, name, hashSecret(secret), JoinScope(scopes), isAgent, now,
	); err != nil {
		return "", nil, fmt.Errorf("pat: insert token: %w", err)
	}
	return secret, &Token{
		ID:        tokenID,
		UserID:    userID,
		Name:      name,
		Scopes:    scopes,
		IsAgent:   isAgent,
		CreatedAt: now,
	}, nil
}

// List returns userID's tokens (newest first), metadata only — the plaintext
// secret is never stored so it is never returned here (SPEC-0002 acceptance:
// "list, metadata only — never the secret").
func (s *Service) List(ctx context.Context, userID string) ([]*Token, error) {
	if userID == "" {
		return nil, errs.Validationf("tokens: owner is required")
	}
	rows, err := s.pool.Query(ctx, `
		SELECT `+tokenColumns+`
		FROM personal_access_tokens t JOIN users u ON u.id = t.user_id
		WHERE t.user_id = $1
		ORDER BY t.created_at DESC`,
		user.IDParam(userID),
	)
	if err != nil {
		return nil, fmt.Errorf("pat: list tokens: %w", err)
	}
	defer rows.Close()

	var out []*Token
	for rows.Next() {
		t, scope, err := scanToken(rows)
		if err != nil {
			return nil, fmt.Errorf("pat: scan token: %w", err)
		}
		t.Scopes, err = ParseScope(scope)
		if err != nil {
			return nil, fmt.Errorf("pat: stored scope invalid for token %s: %w", t.ID, err)
		}
		out = append(out, t)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("pat: list tokens: %w", err)
	}
	return out, nil
}

// tokenColumns is the projection scanToken reads, over a token row t joined
// to its owning user u (the owner renders from the user row).
var tokenColumns = `t.id, t.user_id::text, ` + user.ActorSQL("u") + `, t.name, t.scope, t.is_agent,
	t.created_at, t.last_used_at, t.revoked_at`

// rowScanner is the subset of pgx.Rows / pgx.Row Scan needs.
type rowScanner interface {
	Scan(dest ...any) error
}

// scanToken scans one token row into everything but the parsed Scopes field
// (the caller parses the raw scope string, since Create's single-row
// insert path never needs a round trip through ParseScope).
func scanToken(row rowScanner) (*Token, string, error) {
	var (
		t     Token
		scope string
	)
	if err := row.Scan(&t.ID, &t.UserID, &t.Actor, &t.Name, &scope, &t.IsAgent, &t.CreatedAt, &t.LastUsedAt, &t.RevokedAt); err != nil {
		return nil, "", err
	}
	return &t, scope, nil
}

// Revoke marks userID's token id revoked. It is owner-scoped and uniform:
// an id that does not exist, belongs to a different owner, or is already
// revoked all resolve to errs.ErrNotFound, so the endpoint leaks nothing
// about another owner's tokens (mirrors the artifact store's owner-scoped
// delete, internal/store/delete.go).
func (s *Service) Revoke(ctx context.Context, userID, tokenID string) error {
	if userID == "" || tokenID == "" {
		return errs.Validationf("tokens: owner and id are required")
	}
	tag, err := s.pool.Exec(ctx, `
		UPDATE personal_access_tokens
		SET revoked_at = $1
		WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL`,
		s.now().UTC(), tokenID, user.IDParam(userID),
	)
	if err != nil {
		return fmt.Errorf("pat: revoke token %s: %w", tokenID, err)
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("token %s: %w", tokenID, errs.ErrNotFound)
	}
	return nil
}

// Authenticate resolves a presented bearer secret to its live (unrevoked)
// token, touching last_used_at atomically in the same statement so every
// successful authentication updates it with no extra round trip (issue #74
// acceptance: "Update last_used_at on use"). Any failure — unknown or
// revoked secret, or a suspended owner — is errs.ErrUnauthorized; the lookup
// is a single hashed, parameterized query, so cost does not vary with which
// token matched (SPEC-0007 REQ "Database Operation Standards"). Suspension
// revokes a user's tokens as well; the owner check refuses one that raced
// that revocation (SPEC-0023 "Suspending a user offboards them").
func (s *Service) Authenticate(ctx context.Context, secret string) (*Token, error) {
	if secret == "" {
		return nil, errs.ErrUnauthorized
	}
	now := s.now().UTC()
	row := s.pool.QueryRow(ctx, `
		WITH t AS (
			UPDATE personal_access_tokens
			SET last_used_at = $1
			WHERE token_hash = $2 AND revoked_at IS NULL
			  AND user_id IN (SELECT id FROM users WHERE suspended_at IS NULL)
			RETURNING *)
		SELECT `+tokenColumns+` FROM t JOIN users u ON u.id = t.user_id`,
		now, hashSecret(secret),
	)
	t, scope, err := scanToken(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, errs.ErrUnauthorized
	}
	if err != nil {
		return nil, fmt.Errorf("pat: authenticate: %w", err)
	}
	t.Scopes, err = ParseScope(scope)
	if err != nil {
		return nil, fmt.Errorf("pat: stored scope invalid for token %s: %w", t.ID, err)
	}
	return t, nil
}