550 lines
24 KiB
Go
550 lines
24 KiB
Go
package opsconfig
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/idempotency"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrInvalid = errors.New("invalid runtime configuration")
|
||
|
|
ErrAccessDenied = errors.New("operations access denied")
|
||
|
|
ErrNotFound = errors.New("runtime configuration not found")
|
||
|
|
ErrStateConflict = errors.New("runtime configuration state conflict")
|
||
|
|
ErrVersionConflict = errors.New("runtime configuration version conflict")
|
||
|
|
ErrSelfApproval = errors.New("configuration creator cannot approve own change")
|
||
|
|
)
|
||
|
|
|
||
|
|
var referencePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:/-]{2,199}$`)
|
||
|
|
|
||
|
|
type Service struct {
|
||
|
|
pool *pgxpool.Pool
|
||
|
|
audit *audit.Repository
|
||
|
|
idem *idempotency.Executor
|
||
|
|
}
|
||
|
|
|
||
|
|
type CreateInput struct {
|
||
|
|
TenantID string `json:"tenant_id"`
|
||
|
|
ConfigKey string `json:"config_key"`
|
||
|
|
DisplayName string `json:"display_name"`
|
||
|
|
ConfigKind string `json:"config_kind"`
|
||
|
|
Payload map[string]any `json:"payload"`
|
||
|
|
SecretRefs map[string]string `json:"secret_refs"`
|
||
|
|
ChangeSummary string `json:"change_summary"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Version struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
TenantID string `json:"tenant_id"`
|
||
|
|
ConfigKey string `json:"config_key"`
|
||
|
|
DisplayName string `json:"display_name"`
|
||
|
|
ConfigKind string `json:"config_kind"`
|
||
|
|
VersionNo int `json:"version_no"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Payload map[string]any `json:"payload"`
|
||
|
|
SecretRefs map[string]string `json:"secret_refs"`
|
||
|
|
ChangeSummary string `json:"change_summary"`
|
||
|
|
ValidationResult map[string]any `json:"validation_result"`
|
||
|
|
CreatedBy string `json:"created_by"`
|
||
|
|
SubmittedBy *string `json:"submitted_by,omitempty"`
|
||
|
|
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
|
||
|
|
ApprovedBy *string `json:"approved_by,omitempty"`
|
||
|
|
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||
|
|
PublishedBy *string `json:"published_by,omitempty"`
|
||
|
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||
|
|
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||
|
|
SupersedesID *string `json:"supersedes_id,omitempty"`
|
||
|
|
RollbackOfID *string `json:"rollback_of_id,omitempty"`
|
||
|
|
RowVersion int64 `json:"row_version"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type actor struct {
|
||
|
|
tenantID string
|
||
|
|
tenantPublicID string
|
||
|
|
userID string
|
||
|
|
userPublicID string
|
||
|
|
role string
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository) (*Service, error) {
|
||
|
|
if pool == nil || auditRepository == nil {
|
||
|
|
return nil, errors.New("operations configuration database and audit repository are required")
|
||
|
|
}
|
||
|
|
return &Service{pool: pool, audit: auditRepository, idem: idempotency.NewExecutor(pool)}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Create(ctx context.Context, principal identity.Principal, key, requestID string, input CreateInput) (Version, bool, error) {
|
||
|
|
input.ConfigKey = strings.TrimSpace(strings.ToLower(input.ConfigKey))
|
||
|
|
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||
|
|
input.ConfigKind = strings.TrimSpace(strings.ToUpper(input.ConfigKind))
|
||
|
|
input.ChangeSummary = strings.TrimSpace(input.ChangeSummary)
|
||
|
|
if err := validateInput(input); err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
currentActor, err := s.resolveActor(ctx, principal, input.TenantID, "ops_operator", "ops_approver", "ops_admin")
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
requestBody, _ := json.Marshal(input)
|
||
|
|
result, err := s.idem.Execute(ctx, idempotency.Request{
|
||
|
|
TenantID: ¤tActor.tenantID, ActorKey: "ops:" + currentActor.userPublicID,
|
||
|
|
Scope: "POST /api/v1/ops/config-versions", Key: key,
|
||
|
|
RequestHash: idempotency.Hash(requestBody), ExpiresAfter: 24 * time.Hour,
|
||
|
|
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||
|
|
if _, lockErr := tx.Exec(operationContext, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, currentActor.tenantID+":"+input.ConfigKey); lockErr != nil {
|
||
|
|
return idempotency.Result{}, lockErr
|
||
|
|
}
|
||
|
|
var versionNo int
|
||
|
|
if queryErr := tx.QueryRow(operationContext, `SELECT COALESCE(MAX(version_no),0)+1 FROM runtime_config_version WHERE tenant_id=$1::uuid AND config_key=$2`, currentActor.tenantID, input.ConfigKey).Scan(&versionNo); queryErr != nil {
|
||
|
|
return idempotency.Result{}, queryErr
|
||
|
|
}
|
||
|
|
payloadJSON, _ := json.Marshal(input.Payload)
|
||
|
|
refsJSON, _ := json.Marshal(input.SecretRefs)
|
||
|
|
publicID := uuid.NewString()
|
||
|
|
var internalID string
|
||
|
|
if queryErr := tx.QueryRow(operationContext, `
|
||
|
|
INSERT INTO runtime_config_version(public_id,tenant_id,config_key,display_name,config_kind,version_no,payload,secret_refs,change_summary,created_by)
|
||
|
|
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10::uuid) RETURNING id::text`,
|
||
|
|
publicID, currentActor.tenantID, input.ConfigKey, input.DisplayName, input.ConfigKind, versionNo, payloadJSON, refsJSON, input.ChangeSummary, currentActor.userID).Scan(&internalID); queryErr != nil {
|
||
|
|
return idempotency.Result{}, queryErr
|
||
|
|
}
|
||
|
|
resourceID := internalID
|
||
|
|
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: ¤tActor.tenantID, ActorType: "user", ActorPublicID: ¤tActor.userPublicID, Action: "ops.config.create", ResourceType: "runtime_config_version", ResourceID: &resourceID, RequestID: requestID, Purpose: input.ChangeSummary, Result: "success", Detail: map[string]any{"config_key": input.ConfigKey, "version_no": versionNo, "config_kind": input.ConfigKind}}); auditErr != nil {
|
||
|
|
return idempotency.Result{}, auditErr
|
||
|
|
}
|
||
|
|
item, queryErr := s.getTx(operationContext, tx, currentActor.tenantID, publicID)
|
||
|
|
if queryErr != nil {
|
||
|
|
return idempotency.Result{}, queryErr
|
||
|
|
}
|
||
|
|
body, _ := json.Marshal(item)
|
||
|
|
return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &internalID}, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
var item Version
|
||
|
|
if err := json.Unmarshal(result.Body, &item); err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
return item, !result.Replayed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID, configKey string) ([]Version, error) {
|
||
|
|
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
configKey = strings.TrimSpace(strings.ToLower(configKey))
|
||
|
|
rows, err := s.pool.Query(ctx, versionSelect+` WHERE rcv.tenant_id=$1::uuid AND (NULLIF($2,'') IS NULL OR rcv.config_key=$2) ORDER BY rcv.config_key,rcv.version_no DESC LIMIT 500`, currentActor.tenantID, configKey)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []Version{}
|
||
|
|
for rows.Next() {
|
||
|
|
item, scanErr := scanVersion(rows)
|
||
|
|
if scanErr != nil {
|
||
|
|
return nil, scanErr
|
||
|
|
}
|
||
|
|
items = append(items, item)
|
||
|
|
}
|
||
|
|
return items, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Submit(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Version, error) {
|
||
|
|
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin")
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(context.Background()) }()
|
||
|
|
item, internalID, err := s.lockVersion(ctx, tx, currentActor.tenantID, publicID)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if item.RowVersion != expectedVersion {
|
||
|
|
return Version{}, ErrVersionConflict
|
||
|
|
}
|
||
|
|
if item.Status != "DRAFT" {
|
||
|
|
return Version{}, ErrStateConflict
|
||
|
|
}
|
||
|
|
validation, err := validateVersionTx(ctx, tx, currentActor.tenantID, item)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
validationJSON, _ := json.Marshal(validation)
|
||
|
|
_, err = tx.Exec(ctx, `UPDATE runtime_config_version SET status='PENDING_APPROVAL',validation_result=$2::jsonb,submitted_by=$3::uuid,submitted_at=now(),row_version=row_version+1,updated_at=now() WHERE id=$1::uuid`, internalID, validationJSON, currentActor.userID)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if err = s.record(ctx, tx, currentActor, "ops.config.submit", internalID, requestID, item.ChangeSummary, map[string]any{"config_key": item.ConfigKey, "version_no": item.VersionNo, "checks": validation}); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
updated, err := s.getTx(ctx, tx, currentActor.tenantID, publicID)
|
||
|
|
if err == nil {
|
||
|
|
err = tx.Commit(ctx)
|
||
|
|
}
|
||
|
|
return updated, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Publish(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, input PublishInput, expectedVersion int64) (Version, error) {
|
||
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
||
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 || validateRollout(input.Rollout) != nil {
|
||
|
|
return Version{}, ErrInvalid
|
||
|
|
}
|
||
|
|
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_approver", "ops_admin")
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(context.Background()) }()
|
||
|
|
item, internalID, err := s.lockVersion(ctx, tx, currentActor.tenantID, publicID)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if item.RowVersion != expectedVersion {
|
||
|
|
return Version{}, ErrVersionConflict
|
||
|
|
}
|
||
|
|
if item.Status != "PENDING_APPROVAL" {
|
||
|
|
return Version{}, ErrStateConflict
|
||
|
|
}
|
||
|
|
var creatorID string
|
||
|
|
if err = tx.QueryRow(ctx, `SELECT created_by::text FROM runtime_config_version WHERE id=$1::uuid`, internalID).Scan(&creatorID); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if creatorID == currentActor.userID {
|
||
|
|
return Version{}, ErrSelfApproval
|
||
|
|
}
|
||
|
|
if err = s.checkPublishGatesTx(ctx, tx, currentActor.tenantID, internalID, item); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, currentActor.tenantID+":"+item.ConfigKey); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `UPDATE runtime_config_version SET status='PUBLISHED',approved_by=$2::uuid,approved_at=now(),published_by=$2::uuid,published_at=now(),retired_at=NULL,row_version=row_version+1,updated_at=now() WHERE id=$1::uuid`, internalID, currentActor.userID)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
rolloutID, supersededID, err := s.activateRolloutTx(ctx, tx, currentActor, internalID, item, input.Rollout, input.Reason)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if supersededID != nil {
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE runtime_config_version SET supersedes_id=$2::uuid WHERE id=$1::uuid`, internalID, *supersededID); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err = s.record(ctx, tx, currentActor, "ops.config.publish", internalID, requestID, input.Reason, map[string]any{"config_key": item.ConfigKey, "version_no": item.VersionNo, "rollout_id": rolloutID, "rollout": input.Rollout.String()}); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
updated, err := s.getTx(ctx, tx, currentActor.tenantID, publicID)
|
||
|
|
if err == nil {
|
||
|
|
err = tx.Commit(ctx)
|
||
|
|
}
|
||
|
|
return updated, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Rollback(ctx context.Context, principal identity.Principal, tenantPublicID, targetPublicID, key, requestID string, input PublishInput) (Version, bool, error) {
|
||
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
||
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 || validateRollout(input.Rollout) != nil {
|
||
|
|
return Version{}, false, ErrInvalid
|
||
|
|
}
|
||
|
|
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_approver", "ops_admin")
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
requestBody, _ := json.Marshal(map[string]any{"target_id": targetPublicID, "reason": input.Reason, "rollout": input.Rollout})
|
||
|
|
result, err := s.idem.Execute(ctx, idempotency.Request{
|
||
|
|
TenantID: ¤tActor.tenantID, ActorKey: "ops:" + currentActor.userPublicID,
|
||
|
|
Scope: "POST /api/v1/ops/config-versions/rollback", Key: key,
|
||
|
|
RequestHash: idempotency.Hash(requestBody), ExpiresAfter: 24 * time.Hour,
|
||
|
|
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||
|
|
target, targetInternalID, rollbackErr := s.lockVersion(operationContext, tx, currentActor.tenantID, targetPublicID)
|
||
|
|
if rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
if target.Status != "RETIRED" && target.Status != "PUBLISHED" {
|
||
|
|
return idempotency.Result{}, ErrStateConflict
|
||
|
|
}
|
||
|
|
if rollbackErr = s.checkPublishGatesTx(operationContext, tx, currentActor.tenantID, targetInternalID, target); rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
if _, rollbackErr = tx.Exec(operationContext, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, currentActor.tenantID+":"+target.ConfigKey); rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
var nextVersion int
|
||
|
|
if rollbackErr = tx.QueryRow(operationContext, `SELECT MAX(version_no)+1 FROM runtime_config_version WHERE tenant_id=$1::uuid AND config_key=$2`, currentActor.tenantID, target.ConfigKey).Scan(&nextVersion); rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
newPublicID := uuid.NewString()
|
||
|
|
var newInternalID string
|
||
|
|
if rollbackErr = tx.QueryRow(operationContext, `INSERT INTO runtime_config_version(public_id,tenant_id,config_key,display_name,config_kind,version_no,status,payload,secret_refs,change_summary,validation_result,created_by,submitted_by,submitted_at,approved_by,approved_at,published_by,published_at,rollback_of_id) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,'PUBLISHED',$7::jsonb,$8::jsonb,$9,$10::jsonb,$11::uuid,$11::uuid,now(),$11::uuid,now(),$11::uuid,now(),$12::uuid) RETURNING id::text`, newPublicID, currentActor.tenantID, target.ConfigKey, target.DisplayName, target.ConfigKind, nextVersion, mustJSON(target.Payload), mustJSON(target.SecretRefs), input.Reason, mustJSON(target.ValidationResult), currentActor.userID, targetInternalID).Scan(&newInternalID); rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
rolloutID, supersededID, rollbackErr := s.activateRolloutTx(operationContext, tx, currentActor, newInternalID, target, input.Rollout, input.Reason)
|
||
|
|
if rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
if supersededID != nil {
|
||
|
|
if _, rollbackErr = tx.Exec(operationContext, `UPDATE runtime_config_version SET supersedes_id=$2::uuid WHERE id=$1::uuid`, newInternalID, *supersededID); rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if rollbackErr = s.record(operationContext, tx, currentActor, "ops.config.rollback", newInternalID, requestID, input.Reason, map[string]any{"config_key": target.ConfigKey, "new_version_no": nextVersion, "rollback_of": targetPublicID, "rollout_id": rolloutID, "rollout": input.Rollout.String()}); rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
updated, rollbackErr := s.getTx(operationContext, tx, currentActor.tenantID, newPublicID)
|
||
|
|
if rollbackErr != nil {
|
||
|
|
return idempotency.Result{}, rollbackErr
|
||
|
|
}
|
||
|
|
body, _ := json.Marshal(updated)
|
||
|
|
return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &newInternalID}, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
var item Version
|
||
|
|
if err := json.Unmarshal(result.Body, &item); err != nil {
|
||
|
|
return Version{}, false, err
|
||
|
|
}
|
||
|
|
return item, !result.Replayed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string, roles ...string) (actor, error) {
|
||
|
|
if _, err := uuid.Parse(tenantPublicID); err != nil {
|
||
|
|
return actor{}, ErrInvalid
|
||
|
|
}
|
||
|
|
var result actor
|
||
|
|
err := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.role)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return actor{}, ErrAccessDenied
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return actor{}, err
|
||
|
|
}
|
||
|
|
for _, role := range roles {
|
||
|
|
if result.role == role {
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return actor{}, ErrAccessDenied
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateInput(input CreateInput) error {
|
||
|
|
if !regexp.MustCompile(`^[a-z][a-z0-9_.-]{2,99}$`).MatchString(input.ConfigKey) || input.DisplayName == "" || len([]rune(input.DisplayName)) > 200 || input.ChangeSummary == "" || len([]rune(input.ChangeSummary)) > 500 || input.Payload == nil {
|
||
|
|
return ErrInvalid
|
||
|
|
}
|
||
|
|
switch input.ConfigKind {
|
||
|
|
case "PERSONA", "VOICE", "MODEL", "ROUTING", "AGENT_BUNDLE":
|
||
|
|
default:
|
||
|
|
return ErrInvalid
|
||
|
|
}
|
||
|
|
if _, err := validatePayload(input.ConfigKind, input.Payload, input.SecretRefs); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateVersion(item Version) (map[string]any, error) {
|
||
|
|
return validatePayload(item.ConfigKind, item.Payload, item.SecretRefs)
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateVersionTx(ctx context.Context, tx pgx.Tx, tenantID string, item Version) (map[string]any, error) {
|
||
|
|
validation, err := validateVersion(item)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if item.ConfigKind != "AGENT_BUNDLE" {
|
||
|
|
return validation, nil
|
||
|
|
}
|
||
|
|
components, err := validateAgentBundleComponentsTx(ctx, tx, tenantID, item)
|
||
|
|
if err != nil {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
checks, _ := validation["checks"].([]string)
|
||
|
|
validation["checks"] = append(checks, "component_versions")
|
||
|
|
validation["component_versions"] = components
|
||
|
|
return validation, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateAgentBundleComponentsTx(ctx context.Context, tx pgx.Tx, tenantID string, item Version) ([]map[string]string, error) {
|
||
|
|
expectedKinds := []struct {
|
||
|
|
field string
|
||
|
|
kind string
|
||
|
|
}{
|
||
|
|
{field: "persona_version_id", kind: "PERSONA"},
|
||
|
|
{field: "voice_version_id", kind: "VOICE"},
|
||
|
|
{field: "model_version_id", kind: "MODEL"},
|
||
|
|
{field: "routing_version_id", kind: "ROUTING"},
|
||
|
|
}
|
||
|
|
components := make([]map[string]string, 0, len(expectedKinds))
|
||
|
|
for _, expected := range expectedKinds {
|
||
|
|
publicID, ok := item.Payload[expected.field].(string)
|
||
|
|
if !ok {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
publicID = strings.TrimSpace(publicID)
|
||
|
|
if _, parseErr := uuid.Parse(publicID); parseErr != nil {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
var kind, status string
|
||
|
|
err := tx.QueryRow(ctx, `SELECT config_kind,status FROM runtime_config_version WHERE tenant_id=$1::uuid AND public_id=$2::uuid`, tenantID, publicID).Scan(&kind, &status)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if kind != expected.kind || (status != "PUBLISHED" && status != "RETIRED") {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
components = append(components, map[string]string{"field": expected.field, "version_id": publicID, "config_kind": kind, "status": status})
|
||
|
|
}
|
||
|
|
return components, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validatePayload(kind string, payload map[string]any, refs map[string]string) (map[string]any, error) {
|
||
|
|
encoded, err := json.Marshal(payload)
|
||
|
|
if err != nil || len(encoded) == 0 || len(encoded) > 65536 || containsSensitiveKey(payload) {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
for key, value := range refs {
|
||
|
|
if !referencePattern.MatchString(key) || !referencePattern.MatchString(strings.TrimSpace(value)) {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
}
|
||
|
|
requireString := func(key string) bool { value, ok := payload[key].(string); return ok && strings.TrimSpace(value) != "" }
|
||
|
|
switch kind {
|
||
|
|
case "PERSONA":
|
||
|
|
if !requireString("system_prompt") || !requireString("tone") {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
case "VOICE":
|
||
|
|
if !requireString("provider") || !requireString("voice_resource_ref") || refs["credential_ref"] == "" {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
case "MODEL":
|
||
|
|
if !requireString("provider") || !requireString("model_id") || refs["credential_ref"] == "" {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
case "ROUTING":
|
||
|
|
mode, _ := payload["default_engine_mode"].(string)
|
||
|
|
if mode != "s2s_pure" && mode != "s2s_hybrid" && mode != "cascade" {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
if _, exists := payload["routes"]; !exists {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
case "AGENT_BUNDLE":
|
||
|
|
for _, key := range []string{"persona_version_id", "voice_version_id", "model_version_id", "routing_version_id"} {
|
||
|
|
value, _ := payload[key].(string)
|
||
|
|
if !requireString(key) {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
if _, parseErr := uuid.Parse(strings.TrimSpace(value)); parseErr != nil {
|
||
|
|
return nil, ErrInvalid
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return map[string]any{"valid": true, "schema": "runtime-config-v1", "checked_at": time.Now().UTC().Format(time.RFC3339), "checks": []string{"structure", "required_fields", "secret_references", "sensitive_key_rejection"}}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsSensitiveKey(value any) bool {
|
||
|
|
switch typed := value.(type) {
|
||
|
|
case map[string]any:
|
||
|
|
for key, child := range typed {
|
||
|
|
lower := strings.ToLower(key)
|
||
|
|
for _, forbidden := range []string{"secret", "password", "token", "private_key", "api_key", "credential"} {
|
||
|
|
if strings.Contains(lower, forbidden) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if containsSensitiveKey(child) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
case []any:
|
||
|
|
for _, child := range typed {
|
||
|
|
if containsSensitiveKey(child) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
const versionSelect = `SELECT rcv.public_id::text,t.public_id::text,rcv.config_key,rcv.display_name,rcv.config_kind,rcv.version_no,rcv.status,rcv.payload,rcv.secret_refs,rcv.change_summary,rcv.validation_result,creator.public_id::text,submitter.public_id::text,rcv.submitted_at,approver.public_id::text,rcv.approved_at,publisher.public_id::text,rcv.published_at,rcv.retired_at,supersedes.public_id::text,rollback.public_id::text,rcv.row_version,rcv.created_at,rcv.updated_at FROM runtime_config_version rcv JOIN tenant t ON t.id=rcv.tenant_id JOIN user_account creator ON creator.id=rcv.created_by LEFT JOIN user_account submitter ON submitter.id=rcv.submitted_by LEFT JOIN user_account approver ON approver.id=rcv.approved_by LEFT JOIN user_account publisher ON publisher.id=rcv.published_by LEFT JOIN runtime_config_version supersedes ON supersedes.id=rcv.supersedes_id LEFT JOIN runtime_config_version rollback ON rollback.id=rcv.rollback_of_id`
|
||
|
|
|
||
|
|
type rowScanner interface{ Scan(...any) error }
|
||
|
|
|
||
|
|
func scanVersion(row rowScanner) (Version, error) {
|
||
|
|
var item Version
|
||
|
|
var payloadJSON, refsJSON, validationJSON []byte
|
||
|
|
err := row.Scan(&item.ID, &item.TenantID, &item.ConfigKey, &item.DisplayName, &item.ConfigKind, &item.VersionNo, &item.Status, &payloadJSON, &refsJSON, &item.ChangeSummary, &validationJSON, &item.CreatedBy, &item.SubmittedBy, &item.SubmittedAt, &item.ApprovedBy, &item.ApprovedAt, &item.PublishedBy, &item.PublishedAt, &item.RetiredAt, &item.SupersedesID, &item.RollbackOfID, &item.RowVersion, &item.CreatedAt, &item.UpdatedAt)
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if err = json.Unmarshal(payloadJSON, &item.Payload); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if err = json.Unmarshal(refsJSON, &item.SecretRefs); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
if err = json.Unmarshal(validationJSON, &item.ValidationResult); err != nil {
|
||
|
|
return Version{}, err
|
||
|
|
}
|
||
|
|
return item, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) getTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (Version, error) {
|
||
|
|
item, err := scanVersion(tx.QueryRow(ctx, versionSelect+` WHERE rcv.tenant_id=$1::uuid AND rcv.public_id=$2::uuid`, tenantID, publicID))
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Version{}, ErrNotFound
|
||
|
|
}
|
||
|
|
return item, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) lockVersion(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (Version, string, error) {
|
||
|
|
var internalID string
|
||
|
|
err := tx.QueryRow(ctx, `SELECT id::text FROM runtime_config_version WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, tenantID, publicID).Scan(&internalID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Version{}, "", ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Version{}, "", err
|
||
|
|
}
|
||
|
|
item, err := s.getTx(ctx, tx, tenantID, publicID)
|
||
|
|
return item, internalID, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) record(ctx context.Context, tx pgx.Tx, currentActor actor, action, resourceID, requestID, purpose string, detail map[string]any) error {
|
||
|
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤tActor.tenantID, ActorType: "user", ActorPublicID: ¤tActor.userPublicID, Action: action, ResourceType: "runtime_config_version", ResourceID: &resourceID, RequestID: requestID, Purpose: purpose, Result: "success", Detail: detail})
|
||
|
|
}
|
||
|
|
|
||
|
|
func mustJSON(value any) []byte {
|
||
|
|
encoded, _ := json.Marshal(value)
|
||
|
|
return encoded
|
||
|
|
}
|
||
|
|
|
||
|
|
func (v Version) String() string { return fmt.Sprintf("%s@%d", v.ConfigKey, v.VersionNo) }
|