사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
247 lines
8.2 KiB
Go
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
|
|
}
|