Initial import — snapshot from admin host /srv/gosec/gsc-ops-api

This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.

The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.

Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.

Future changes go through PRs against this repo — no more bin-only
deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (gsc-ops-api init)
2026-05-03 20:06:02 +02:00
commit 3847eb2036
68 changed files with 12982 additions and 0 deletions

View File

@@ -0,0 +1,413 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// DatabaseService handles tenant and user database operations
type DatabaseService struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewDatabaseService creates a new database service
func NewDatabaseService(pool *pgxpool.Pool, logger zerolog.Logger) *DatabaseService {
return &DatabaseService{
pool: pool,
logger: logger.With().Str("service", "database").Logger(),
}
}
// ListTenants lists tenants with optional filters
func (s *DatabaseService) ListTenants(ctx context.Context, params types.ListParams) ([]types.Tenant, int64, error) {
params = types.DefaultListParams(params)
countQuery := `SELECT COUNT(*) FROM admin.tenants WHERE 1=1`
listQuery := `SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color,
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at
FROM admin.tenants WHERE 1=1`
args := []interface{}{}
argIdx := 1
if params.Status != "" {
if params.Status == "active" {
countQuery += " AND is_active = true"
listQuery += " AND is_active = true"
} else if params.Status == "inactive" {
countQuery += " AND is_active = false"
listQuery += " AND is_active = false"
}
}
if params.Search != "" {
countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx)
listQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx)
args = append(args, "%"+params.Search+"%")
argIdx++
}
var total int64
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count query failed: %w", err)
}
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, params.Limit, params.Offset)
rows, err := s.pool.Query(ctx, listQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("list query failed: %w", err)
}
defer rows.Close()
tenants := make([]types.Tenant, 0)
for rows.Next() {
var t types.Tenant
var metadataJSON []byte
if err := rows.Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain,
&t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours,
&t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, 0, fmt.Errorf("scan failed: %w", err)
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &t.Metadata)
}
tenants = append(tenants, t)
}
return tenants, total, nil
}
// GetTenant gets a tenant by ID
func (s *DatabaseService) GetTenant(ctx context.Context, id uuid.UUID) (*types.Tenant, error) {
var t types.Tenant
var metadataJSON []byte
err := s.pool.QueryRow(ctx,
`SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color,
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at
FROM admin.tenants WHERE id = $1`, id).
Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain,
&t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours,
&t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return nil, err
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &t.Metadata)
}
return &t, nil
}
// CreateTenant creates a new tenant
func (s *DatabaseService) CreateTenant(ctx context.Context, req *types.TenantCreate) (*types.Tenant, error) {
id := uuid.New()
now := time.Now().UTC()
var metadataJSON []byte
if req.Metadata != nil {
var err error
metadataJSON, err = json.Marshal(req.Metadata)
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
}
_, err := s.pool.Exec(ctx,
`INSERT INTO admin.tenants (id, customer_id, code, name, display_name, domain, logo_url, primary_color,
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true, $12, $13, $13)`,
id, req.CustomerID, req.Code, req.Name, nilIfEmpty(req.DisplayName), nilIfEmpty(req.Domain),
nilIfEmpty(req.LogoURL), nilIfEmpty(req.PrimaryColor),
req.MaxUsers, req.MaxStorageGB, req.MaxRecordingHours, metadataJSON, now)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
return s.GetTenant(ctx, id)
}
// UpdateTenant updates a tenant
func (s *DatabaseService) UpdateTenant(ctx context.Context, id uuid.UUID, req *types.TenantUpdate) (*types.Tenant, error) {
setClauses := []string{}
args := []interface{}{}
argIdx := 1
if req.Name != nil {
setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx))
args = append(args, *req.Name)
argIdx++
}
if req.DisplayName != nil {
setClauses = append(setClauses, fmt.Sprintf("display_name = $%d", argIdx))
args = append(args, *req.DisplayName)
argIdx++
}
if req.Domain != nil {
setClauses = append(setClauses, fmt.Sprintf("domain = $%d", argIdx))
args = append(args, *req.Domain)
argIdx++
}
if req.LogoURL != nil {
setClauses = append(setClauses, fmt.Sprintf("logo_url = $%d", argIdx))
args = append(args, *req.LogoURL)
argIdx++
}
if req.PrimaryColor != nil {
setClauses = append(setClauses, fmt.Sprintf("primary_color = $%d", argIdx))
args = append(args, *req.PrimaryColor)
argIdx++
}
if req.MaxUsers != nil {
setClauses = append(setClauses, fmt.Sprintf("max_users = $%d", argIdx))
args = append(args, *req.MaxUsers)
argIdx++
}
if req.MaxStorageGB != nil {
setClauses = append(setClauses, fmt.Sprintf("max_storage_gb = $%d", argIdx))
args = append(args, *req.MaxStorageGB)
argIdx++
}
if req.MaxRecordingHours != nil {
setClauses = append(setClauses, fmt.Sprintf("max_recording_hours = $%d", argIdx))
args = append(args, *req.MaxRecordingHours)
argIdx++
}
if req.IsActive != nil {
setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx))
args = append(args, *req.IsActive)
argIdx++
}
if req.Metadata != nil {
metadataJSON, err := json.Marshal(req.Metadata)
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx))
args = append(args, metadataJSON)
argIdx++
}
if len(setClauses) == 0 {
return s.GetTenant(ctx, id)
}
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx))
args = append(args, time.Now().UTC())
argIdx++
args = append(args, id)
query := fmt.Sprintf("UPDATE admin.tenants SET %s WHERE id = $%d",
join(setClauses, ", "), argIdx)
_, err := s.pool.Exec(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
return s.GetTenant(ctx, id)
}
// SoftDeleteTenant deactivates a tenant
func (s *DatabaseService) SoftDeleteTenant(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx,
`UPDATE admin.tenants SET is_active = false, updated_at = $1 WHERE id = $2`,
time.Now().UTC(), id)
return err
}
// ListUsers lists users with optional filters
func (s *DatabaseService) ListUsers(ctx context.Context, params types.ListParams) ([]types.DBUser, int64, error) {
params = types.DefaultListParams(params)
countQuery := `SELECT COUNT(*) FROM admin.users WHERE 1=1`
listQuery := `SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status,
last_login_at, last_activity_at, metadata, created_at, updated_at
FROM admin.users WHERE 1=1`
args := []interface{}{}
argIdx := 1
if params.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
listQuery += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, params.Status)
argIdx++
}
if params.Search != "" {
countQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx)
listQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx)
args = append(args, "%"+params.Search+"%")
argIdx++
}
var total int64
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count query failed: %w", err)
}
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, params.Limit, params.Offset)
rows, err := s.pool.Query(ctx, listQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("list query failed: %w", err)
}
defer rows.Close()
users := make([]types.DBUser, 0)
for rows.Next() {
var u types.DBUser
var metadataJSON []byte
if err := rows.Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status,
&u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, 0, fmt.Errorf("scan failed: %w", err)
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &u.Metadata)
}
users = append(users, u)
}
return users, total, nil
}
// GetUser gets a user by ID
func (s *DatabaseService) GetUser(ctx context.Context, id uuid.UUID) (*types.DBUser, error) {
var u types.DBUser
var metadataJSON []byte
err := s.pool.QueryRow(ctx,
`SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status,
last_login_at, last_activity_at, metadata, created_at, updated_at
FROM admin.users WHERE id = $1`, id).
Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status,
&u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &u.Metadata)
}
return &u, nil
}
// CreateUser creates a new user record
func (s *DatabaseService) CreateUser(ctx context.Context, req *types.DBUserCreate) (*types.DBUser, error) {
id := uuid.New()
now := time.Now().UTC()
var metadataJSON []byte
if req.Metadata != nil {
var err error
metadataJSON, err = json.Marshal(req.Metadata)
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
}
_, err := s.pool.Exec(ctx,
`INSERT INTO admin.users (id, gscsid, first_name, last_name, display_name, email, timezone, locale, status, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $10)`,
id, req.GscSID, nilIfEmpty(req.FirstName), nilIfEmpty(req.LastName), nilIfEmpty(req.DisplayName), nilIfEmpty(req.Email), nilIfEmpty(req.Timezone), nilIfEmpty(req.Locale), metadataJSON, now)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
return s.GetUser(ctx, id)
}
// UpdateUser updates a user record
func (s *DatabaseService) UpdateUser(ctx context.Context, id uuid.UUID, req *types.DBUserUpdate) (*types.DBUser, error) {
setClauses := []string{}
args := []interface{}{}
argIdx := 1
if req.Timezone != nil {
setClauses = append(setClauses, fmt.Sprintf("timezone = $%d", argIdx))
args = append(args, *req.Timezone)
argIdx++
}
if req.Locale != nil {
setClauses = append(setClauses, fmt.Sprintf("locale = $%d", argIdx))
args = append(args, *req.Locale)
argIdx++
}
if req.Status != nil {
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
args = append(args, *req.Status)
argIdx++
}
if req.LastLoginAt != nil {
setClauses = append(setClauses, fmt.Sprintf("last_login_at = $%d", argIdx))
args = append(args, *req.LastLoginAt)
argIdx++
}
if req.LastActivityAt != nil {
setClauses = append(setClauses, fmt.Sprintf("last_activity_at = $%d", argIdx))
args = append(args, *req.LastActivityAt)
argIdx++
}
if req.Metadata != nil {
metadataJSON, err := json.Marshal(req.Metadata)
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx))
args = append(args, metadataJSON)
argIdx++
}
if len(setClauses) == 0 {
return s.GetUser(ctx, id)
}
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx))
args = append(args, time.Now().UTC())
argIdx++
args = append(args, id)
query := fmt.Sprintf("UPDATE admin.users SET %s WHERE id = $%d",
join(setClauses, ", "), argIdx)
_, err := s.pool.Exec(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
return s.GetUser(ctx, id)
}
// DeactivateUser deactivates a user
func (s *DatabaseService) DeactivateUser(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx,
`UPDATE admin.users SET status = 'inactive', updated_at = $1 WHERE id = $2`,
now, id)
return err
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func join(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}