iop/apps/control-plane/internal/credentialstore/projection.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

247 lines
8.2 KiB
Go

package credentialstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"google.golang.org/protobuf/proto"
iop "iop/proto/gen/iop"
)
const defaultProjectionTTL = 5 * time.Minute
var (
ErrProjectionGenerationMissing = errors.New("credentialstore: projection generation is missing")
ErrProjectionTooLarge = errors.New("credentialstore: projection exceeds configured bounds")
)
// ProjectionLimits bounds the amount of verifier and route metadata loaded
// into one immutable projection. MaxBytes is measured using protobuf wire
// size after the snapshot is assembled.
type ProjectionLimits struct {
MaxTokens int
MaxRoutes int
MaxBytes int
}
// DefaultProjectionLimits returns conservative hard bounds for one Edge
// authorization projection.
func DefaultProjectionLimits() ProjectionLimits {
return ProjectionLimits{
MaxTokens: 4096,
MaxRoutes: 16384,
MaxBytes: 4 << 20,
}
}
// ProjectionBuildOptions controls snapshot expiry, bounds, and time. Clock is
// injectable so expiry fixtures do not depend on wall-clock sleeps.
type ProjectionBuildOptions struct {
TTL time.Duration
Limits ProjectionLimits
Clock func() time.Time
}
func normalizeProjectionBuildOptions(opts ProjectionBuildOptions) (ProjectionBuildOptions, error) {
if opts.TTL < 0 {
return ProjectionBuildOptions{}, fmt.Errorf("credentialstore: projection ttl must be positive")
}
if opts.TTL == 0 {
opts.TTL = defaultProjectionTTL
}
if opts.Clock == nil {
opts.Clock = time.Now
}
defaults := DefaultProjectionLimits()
if opts.Limits.MaxTokens < 0 || opts.Limits.MaxRoutes < 0 || opts.Limits.MaxBytes < 0 {
return ProjectionBuildOptions{}, fmt.Errorf("credentialstore: projection limits must not be negative")
}
if opts.Limits.MaxTokens == 0 {
opts.Limits.MaxTokens = defaults.MaxTokens
}
if opts.Limits.MaxRoutes == 0 {
opts.Limits.MaxRoutes = defaults.MaxRoutes
}
if opts.Limits.MaxBytes == 0 {
opts.Limits.MaxBytes = defaults.MaxBytes
}
return opts, nil
}
// ProjectionGeneration returns the durable generation currently committed by
// token, slot, and route mutations.
func (s *Store) ProjectionGeneration(ctx context.Context) (uint64, error) {
if s == nil || s.db == nil {
return 0, ErrProjectionGenerationMissing
}
var generation int64
if err := s.db.QueryRowContext(ctx, `SELECT generation FROM principal_projection_state WHERE singleton=1`).Scan(&generation); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrProjectionGenerationMissing
}
return 0, err
}
if generation < 0 {
return 0, fmt.Errorf("credentialstore: invalid projection generation %d", generation)
}
return uint64(generation), nil
}
// bumpProjectionGenerationTx increments the singleton generation inside the
// caller's mutation transaction. A failure rolls the entire mutation back.
func (s *Store) bumpProjectionGenerationTx(ctx context.Context, tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `UPDATE principal_projection_state SET generation=generation+1 WHERE singleton=1`)
if err != nil {
return fmt.Errorf("credentialstore: bump projection generation: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("credentialstore: inspect projection generation bump: %w", err)
}
if affected != 1 {
return ErrProjectionGenerationMissing
}
return nil
}
// BuildPrincipalProjection reads one transactionally consistent, secret-free
// snapshot. Only active token digests and routes whose credential slot is also
// active are included. Ciphertext, envelope metadata, and raw credentials are
// deliberately absent from the protobuf contract.
func (s *Store) BuildPrincipalProjection(ctx context.Context, opts ProjectionBuildOptions) (*iop.PrincipalProjection, error) {
if s == nil || s.db == nil {
return nil, ErrProjectionGenerationMissing
}
opts, err := normalizeProjectionBuildOptions(opts)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return nil, fmt.Errorf("credentialstore: begin projection snapshot: %w", err)
}
defer func() { _ = tx.Rollback() }()
var generation int64
if err := tx.QueryRowContext(ctx, `SELECT generation FROM principal_projection_state WHERE singleton=1`).Scan(&generation); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrProjectionGenerationMissing
}
return nil, fmt.Errorf("credentialstore: read projection generation: %w", err)
}
if generation < 0 {
return nil, fmt.Errorf("credentialstore: invalid projection generation %d", generation)
}
tokens, err := s.projectedTokensTx(ctx, tx, opts.Limits.MaxTokens)
if err != nil {
return nil, err
}
routes, err := s.projectedRoutesTx(ctx, tx, opts.Limits.MaxRoutes)
if err != nil {
return nil, err
}
issuedAt := opts.Clock().UTC()
snapshot := &iop.PrincipalProjection{
Generation: uint64(generation),
IssuedAtUnixNano: issuedAt.UnixNano(),
ExpiresAtUnixNano: issuedAt.Add(opts.TTL).UnixNano(),
Tokens: tokens,
Routes: routes,
}
if size := proto.Size(snapshot); size > opts.Limits.MaxBytes {
return nil, fmt.Errorf("%w: bytes=%d max=%d", ErrProjectionTooLarge, size, opts.Limits.MaxBytes)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("credentialstore: commit projection snapshot: %w", err)
}
return snapshot, nil
}
func (s *Store) projectedTokensTx(ctx context.Context, tx *sql.Tx, max int) ([]*iop.ProjectedPrincipalToken, error) {
rows, err := tx.QueryContext(ctx, s.bind(`
SELECT t.digest, p.id, p.alias, t.token_ref, t.revision
FROM tokens t
JOIN principals p ON p.id=t.principal_id
WHERE t.status=?
ORDER BY p.id, t.token_ref
LIMIT ?
`), StatusActive, max+1)
if err != nil {
return nil, fmt.Errorf("credentialstore: query projected tokens: %w", err)
}
defer rows.Close()
tokens := make([]*iop.ProjectedPrincipalToken, 0)
for rows.Next() {
var token iop.ProjectedPrincipalToken
var revision int64
if err := rows.Scan(&token.TokenDigestSha256, &token.PrincipalRef, &token.PrincipalAlias, &token.TokenRef, &revision); err != nil {
return nil, fmt.Errorf("credentialstore: scan projected token: %w", err)
}
if len(tokens) == max {
return nil, fmt.Errorf("%w: tokens exceed %d", ErrProjectionTooLarge, max)
}
if revision < 0 {
return nil, fmt.Errorf("credentialstore: token %s has negative revision", token.TokenRef)
}
token.TokenRevision = uint64(revision)
tokens = append(tokens, &token)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("credentialstore: iterate projected tokens: %w", err)
}
return tokens, nil
}
func (s *Store) projectedRoutesTx(ctx context.Context, tx *sql.Tx, max int) ([]*iop.ProjectedPrincipalRoute, error) {
rows, err := tx.QueryContext(ctx, s.bind(`
SELECT r.id, r.alias, r.principal_id, r.slot_id, r.profile_id,
r.upstream_model, r.resource_selector, r.revision, s.revision
FROM routes r
JOIN credential_slots s ON s.id=r.slot_id AND s.principal_id=r.principal_id
WHERE r.status=? AND s.status=?
ORDER BY r.principal_id, r.id
LIMIT ?
`), StatusActive, StatusActive, max+1)
if err != nil {
return nil, fmt.Errorf("credentialstore: query projected routes: %w", err)
}
defer rows.Close()
routes := make([]*iop.ProjectedPrincipalRoute, 0)
for rows.Next() {
var route iop.ProjectedPrincipalRoute
var alias sql.NullString
var routeRevision, credentialRevision int64
if err := rows.Scan(
&route.RouteId, &alias, &route.PrincipalRef, &route.CredentialSlotRef,
&route.ProfileId, &route.UpstreamModel, &route.ResourceSelector,
&routeRevision, &credentialRevision,
); err != nil {
return nil, fmt.Errorf("credentialstore: scan projected route: %w", err)
}
if len(routes) == max {
return nil, fmt.Errorf("%w: routes exceed %d", ErrProjectionTooLarge, max)
}
if routeRevision < 0 || credentialRevision < 0 {
return nil, fmt.Errorf("credentialstore: route %s has a negative revision", route.RouteId)
}
if alias.Valid {
route.RouteAlias = alias.String
}
route.RouteRevision = uint64(routeRevision)
route.CredentialRevision = uint64(credentialRevision)
routes = append(routes, &route)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("credentialstore: iterate projected routes: %w", err)
}
return routes, nil
}