사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
538 lines
18 KiB
Go
538 lines
18 KiB
Go
package credentialstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
// StatusDraft is the initial state of a credential slot. A draft cannot
|
|
// serve a route until a compatible binding validates its envelope key.
|
|
StatusDraft = "draft"
|
|
|
|
// ErrSlotNotFound is returned when a requested credential slot does not exist.
|
|
ErrSlotNotFound = errors.New("credentialstore: slot not found")
|
|
|
|
// ErrSlotAliasAlreadyExists is returned when a slot alias is already in use for a principal.
|
|
ErrSlotAliasAlreadyExists = errors.New("credentialstore: slot alias already exists for principal")
|
|
|
|
// ErrSlotRevoked is returned when attempting to modify a revoked slot.
|
|
ErrSlotRevoked = errors.New("credentialstore: revoked slot cannot be modified")
|
|
|
|
// ErrSlotNotActive is returned when an operation requires an active slot.
|
|
ErrSlotNotActive = errors.New("credentialstore: slot is not active")
|
|
)
|
|
|
|
const (
|
|
CredentialKindBearer = "bearer"
|
|
CredentialKindAPIKey = "api_key"
|
|
)
|
|
|
|
func normalizeCredentialPart(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func normalizeAlias(value string) string {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func knownCredentialKind(kind string) bool {
|
|
switch kind {
|
|
case CredentialKindBearer, CredentialKindAPIKey:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CreateSlotInput holds parameters for creating a new credential slot.
|
|
//
|
|
// SlotID is an additive, optional caller-generated UUID. When set it lets the
|
|
// caller bind create-time secret AAD to a stable slot id before sealing; when
|
|
// empty (the legacy zero value) the store generates the id.
|
|
type CreateSlotInput struct {
|
|
SlotID string
|
|
PrincipalID string
|
|
Vendor string
|
|
CredentialKind string
|
|
Alias string
|
|
Envelope SecretEnvelope
|
|
}
|
|
|
|
// RotateSlotSecretInput holds parameters for rotating secret envelope on an existing slot.
|
|
type RotateSlotSecretInput struct {
|
|
PrincipalID string
|
|
SlotID string
|
|
CurrentRevision int64
|
|
Envelope SecretEnvelope
|
|
}
|
|
|
|
// CredentialSlotRecord represents a persisted credential slot row.
|
|
type CredentialSlotRecord struct {
|
|
ID string
|
|
PrincipalID string
|
|
Vendor string
|
|
CredentialKind string
|
|
Alias string
|
|
Status string
|
|
Revision int64
|
|
Envelope SecretEnvelope
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
RevokedAt *time.Time
|
|
}
|
|
|
|
// SlotRevisionRecord represents a persisted historical secret revision for a slot.
|
|
type SlotRevisionRecord struct {
|
|
ID string
|
|
SlotID string
|
|
Revision int64
|
|
Envelope SecretEnvelope
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// CreateSlot creates a principal-scoped credential slot with an initial secret revision.
|
|
func (s *Store) CreateSlot(ctx context.Context, in CreateSlotInput) (*CredentialSlotRecord, error) {
|
|
if strings.TrimSpace(in.PrincipalID) == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateSlot: principal_id is required")
|
|
}
|
|
vendor := normalizeCredentialPart(in.Vendor)
|
|
if vendor == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateSlot: vendor is required")
|
|
}
|
|
kind := normalizeCredentialPart(in.CredentialKind)
|
|
if !knownCredentialKind(kind) {
|
|
return nil, fmt.Errorf("credentialstore: CreateSlot: credential_kind is not recognized")
|
|
}
|
|
alias := normalizeAlias(in.Alias)
|
|
|
|
if err := s.validateEnvelopeKey(ctx, in.Envelope); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slotID := strings.TrimSpace(in.SlotID)
|
|
if slotID == "" {
|
|
slotID = uuid.New().String()
|
|
} else if _, err := uuid.Parse(slotID); err != nil {
|
|
return nil, fmt.Errorf("credentialstore: CreateSlot: slot_id must be a valid UUID")
|
|
}
|
|
revisionID := uuid.New().String()
|
|
now := time.Now().UTC()
|
|
|
|
var record CredentialSlotRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
var pCount int
|
|
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM principals WHERE id=?`), in.PrincipalID).Scan(&pCount); err != nil {
|
|
return fmt.Errorf("credentialstore: check principal: %w", err)
|
|
}
|
|
if pCount == 0 {
|
|
return fmt.Errorf("credentialstore: principal not found: %s", in.PrincipalID)
|
|
}
|
|
|
|
if alias != "" {
|
|
var sCount int
|
|
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM credential_slots WHERE principal_id=? AND alias=?`), in.PrincipalID, alias).Scan(&sCount); err != nil {
|
|
return fmt.Errorf("credentialstore: check slot alias: %w", err)
|
|
}
|
|
if sCount > 0 {
|
|
return ErrSlotAliasAlreadyExists
|
|
}
|
|
}
|
|
var aliasArg any
|
|
if alias != "" {
|
|
aliasArg = alias
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, s.bind(`
|
|
INSERT INTO credential_slots (id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), slotID, in.PrincipalID, vendor, kind, aliasArg, StatusDraft, 0,
|
|
in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
|
|
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD,
|
|
now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert slot: %w", err)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, s.bind(`
|
|
INSERT INTO credential_slot_revisions (id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), revisionID, slotID, 0,
|
|
in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
|
|
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD,
|
|
now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert slot revision: %w", err)
|
|
}
|
|
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
|
|
record = CredentialSlotRecord{
|
|
ID: slotID,
|
|
PrincipalID: in.PrincipalID,
|
|
Vendor: vendor,
|
|
CredentialKind: kind,
|
|
Alias: alias,
|
|
Status: StatusDraft,
|
|
Revision: 0,
|
|
Envelope: in.Envelope,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
// GetSlot retrieves a credential slot by principal ID and slot ID.
|
|
func (s *Store) GetSlot(ctx context.Context, principalID, slotID string) (*CredentialSlotRecord, error) {
|
|
var r CredentialSlotRecord
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := s.db.QueryRowContext(ctx, s.bind(`
|
|
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
|
|
FROM credential_slots
|
|
WHERE principal_id=? AND id=?
|
|
`), principalID, slotID).Scan(
|
|
&r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &alias, &r.Status, &r.Revision,
|
|
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
|
|
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
|
|
&createdAt, &updatedAt, &revokedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrSlotNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if alias.Valid {
|
|
r.Alias = alias.String
|
|
}
|
|
r.CreatedAt, _ = parseTime(createdAt)
|
|
r.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
r.RevokedAt = &parsed
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// GetSlotByAlias retrieves a credential slot by principal ID and alias.
|
|
func (s *Store) GetSlotByAlias(ctx context.Context, principalID, alias string) (*CredentialSlotRecord, error) {
|
|
var r CredentialSlotRecord
|
|
var aliasValue sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := s.db.QueryRowContext(ctx, s.bind(`
|
|
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
|
|
FROM credential_slots
|
|
WHERE principal_id=? AND alias=?
|
|
`), principalID, normalizeAlias(alias)).Scan(
|
|
&r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &aliasValue, &r.Status, &r.Revision,
|
|
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
|
|
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
|
|
&createdAt, &updatedAt, &revokedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrSlotNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if aliasValue.Valid {
|
|
r.Alias = aliasValue.String
|
|
}
|
|
r.CreatedAt, _ = parseTime(createdAt)
|
|
r.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
r.RevokedAt = &parsed
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// ListSlots returns all credential slots belonging to a principal.
|
|
func (s *Store) ListSlots(ctx context.Context, principalID string) ([]CredentialSlotRecord, error) {
|
|
rows, err := s.db.QueryContext(ctx, s.bind(`
|
|
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
|
|
FROM credential_slots
|
|
WHERE principal_id=?
|
|
ORDER BY alias
|
|
`), principalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []CredentialSlotRecord
|
|
for rows.Next() {
|
|
var r CredentialSlotRecord
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
if err := rows.Scan(
|
|
&r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &alias, &r.Status, &r.Revision,
|
|
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
|
|
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
|
|
&createdAt, &updatedAt, &revokedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if alias.Valid {
|
|
r.Alias = alias.String
|
|
}
|
|
r.CreatedAt, _ = parseTime(createdAt)
|
|
r.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
r.RevokedAt = &parsed
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RotateSlotSecret replaces the secret envelope on a slot using CAS on revision.
|
|
func (s *Store) RotateSlotSecret(ctx context.Context, in RotateSlotSecretInput) (*CredentialSlotRecord, error) {
|
|
if strings.TrimSpace(in.PrincipalID) == "" || strings.TrimSpace(in.SlotID) == "" {
|
|
return nil, fmt.Errorf("credentialstore: RotateSlotSecret: principal_id and slot_id are required")
|
|
}
|
|
|
|
if err := s.validateEnvelopeKey(ctx, in.Envelope); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var updated CredentialSlotRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
var currentStatus string
|
|
var currentRev int64
|
|
err := tx.QueryRowContext(ctx, s.bind(`
|
|
SELECT status, revision FROM credential_slots WHERE principal_id=? AND id=?
|
|
`), in.PrincipalID, in.SlotID).Scan(¤tStatus, ¤tRev)
|
|
if err == sql.ErrNoRows {
|
|
return ErrSlotNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentStatus == StatusRevoked {
|
|
return ErrSlotRevoked
|
|
}
|
|
if currentRev != in.CurrentRevision {
|
|
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, currentRev, in.CurrentRevision)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
nextRev := currentRev + 1
|
|
res, err := tx.ExecContext(ctx, s.bind(`
|
|
UPDATE credential_slots
|
|
SET revision=?, algorithm=?, key_id=?, key_version=?, nonce=?, ciphertext=?, aad=?, updated_at=?
|
|
WHERE principal_id=? AND id=? AND revision=?
|
|
`), nextRev, in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
|
|
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now,
|
|
in.PrincipalID, in.SlotID, in.CurrentRevision,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != 1 {
|
|
return fmt.Errorf("%w: failed to update slot revision", ErrRevisionMismatch)
|
|
}
|
|
|
|
revisionID := uuid.New().String()
|
|
if _, err := tx.ExecContext(ctx, s.bind(`
|
|
INSERT INTO credential_slot_revisions (id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), revisionID, in.SlotID, nextRev,
|
|
in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
|
|
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert slot revision: %w", err)
|
|
}
|
|
|
|
if err := s.loadSlotTx(ctx, tx, in.PrincipalID, in.SlotID, &updated); err != nil {
|
|
return err
|
|
}
|
|
return s.bumpProjectionGenerationTx(ctx, tx)
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
// DisableSlot disables an active credential slot using CAS on revision.
|
|
func (s *Store) DisableSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) {
|
|
return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusActive}, StatusDisabled)
|
|
}
|
|
|
|
// EnableSlot enables a disabled credential slot using CAS on revision. The
|
|
// stored envelope key and at least one non-revoked compatible binding must
|
|
// still be valid before the slot can become active again.
|
|
func (s *Store) EnableSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) {
|
|
return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusDisabled}, StatusActive)
|
|
}
|
|
|
|
// RevokeSlot revokes an active or disabled credential slot. Revocation is permanent.
|
|
func (s *Store) RevokeSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) {
|
|
return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusDraft, StatusActive, StatusDisabled}, StatusRevoked)
|
|
}
|
|
|
|
func (s *Store) casSlotStatus(ctx context.Context, principalID, slotID string, currentRevision int64, fromStatuses []string, toStatus string) (*CredentialSlotRecord, error) {
|
|
var updated CredentialSlotRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
if toStatus == StatusActive {
|
|
var slot CredentialSlotRecord
|
|
if err := s.loadSlotTx(ctx, tx, principalID, slotID, &slot); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrSlotNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := s.validateEnvelopeKey(ctx, slot.Envelope); err != nil {
|
|
return err
|
|
}
|
|
ok, err := s.slotHasCompatibleBindingTx(ctx, tx, slot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: slot has no compatible binding", ErrSlotNotActive)
|
|
}
|
|
}
|
|
now := time.Now().UTC()
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",")
|
|
query := `UPDATE credential_slots SET status=?, revision=revision+1, updated_at=?, revoked_at=?
|
|
WHERE principal_id=? AND id=? AND revision=? AND status IN (` + placeholders + `)`
|
|
var revokedArg interface{}
|
|
if toStatus == StatusRevoked {
|
|
revokedArg = now
|
|
}
|
|
args := []any{toStatus, now, revokedArg, principalID, slotID, currentRevision}
|
|
for _, st := range fromStatuses {
|
|
args = append(args, st)
|
|
}
|
|
res, err := tx.ExecContext(ctx, s.bind(query), args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != 1 {
|
|
return s.classifySlotTransitionMiss(ctx, tx, principalID, slotID, currentRevision, fromStatuses)
|
|
}
|
|
if err := s.loadSlotTx(ctx, tx, principalID, slotID, &updated); err != nil {
|
|
return err
|
|
}
|
|
return s.bumpProjectionGenerationTx(ctx, tx)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
func (s *Store) classifySlotTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, slotID string, currentRevision int64, fromStatuses []string) error {
|
|
var status string
|
|
var revision int64
|
|
err := tx.QueryRowContext(ctx, s.bind(`
|
|
SELECT status, revision FROM credential_slots WHERE principal_id=? AND id=?
|
|
`), principalID, slotID).Scan(&status, &revision)
|
|
if err == sql.ErrNoRows {
|
|
return ErrSlotNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if revision != currentRevision {
|
|
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision)
|
|
}
|
|
if status == StatusRevoked {
|
|
return ErrSlotRevoked
|
|
}
|
|
return fmt.Errorf("%w: have %s want one of %s", ErrSlotNotActive, status, strings.Join(fromStatuses, ","))
|
|
}
|
|
|
|
func (s *Store) loadSlotTx(ctx context.Context, tx *sql.Tx, principalID, slotID string, target *CredentialSlotRecord) error {
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := tx.QueryRowContext(ctx, s.bind(`
|
|
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
|
|
FROM credential_slots
|
|
WHERE principal_id=? AND id=?
|
|
`), principalID, slotID).Scan(
|
|
&target.ID, &target.PrincipalID, &target.Vendor, &target.CredentialKind, &alias, &target.Status, &target.Revision,
|
|
&target.Envelope.Algorithm, &target.Envelope.KeyID, &target.Envelope.KeyVersion,
|
|
&target.Envelope.Nonce, &target.Envelope.Ciphertext, &target.Envelope.AAD,
|
|
&createdAt, &updatedAt, &revokedAt,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if alias.Valid {
|
|
target.Alias = alias.String
|
|
}
|
|
target.CreatedAt, _ = parseTime(createdAt)
|
|
target.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
target.RevokedAt = &parsed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListSlotRevisions returns all historical secret envelope revisions for a slot.
|
|
func (s *Store) ListSlotRevisions(ctx context.Context, principalID, slotID string) ([]SlotRevisionRecord, error) {
|
|
// First verify slot ownership
|
|
if _, err := s.GetSlot(ctx, principalID, slotID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(ctx, s.bind(`
|
|
SELECT id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at
|
|
FROM credential_slot_revisions
|
|
WHERE slot_id=?
|
|
ORDER BY revision ASC
|
|
`), slotID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []SlotRevisionRecord
|
|
for rows.Next() {
|
|
var r SlotRevisionRecord
|
|
var createdAt string
|
|
if err := rows.Scan(
|
|
&r.ID, &r.SlotID, &r.Revision,
|
|
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
|
|
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
|
|
&createdAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
r.CreatedAt, _ = parseTime(createdAt)
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|