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

191
internal/client/asterisk.go Normal file
View File

@@ -0,0 +1,191 @@
package client
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
)
// AsteriskServer defines an Asterisk AMI endpoint
type AsteriskServer struct {
Host string `yaml:"host"`
AMIPort int `yaml:"amiPort"`
}
// AsteriskClient manages AMI connections to Asterisk servers
type AsteriskClient struct {
servers []AsteriskServer
user string
secret string
logger zerolog.Logger
}
// NewAsteriskClient creates a new Asterisk AMI client
func NewAsteriskClient(servers []AsteriskServer, user, secret string, logger zerolog.Logger) *AsteriskClient {
return &AsteriskClient{
servers: servers,
user: user,
secret: secret,
logger: logger.With().Str("client", "asterisk").Logger(),
}
}
// ReloadPJSIP sends a PJSIP reload command to all Asterisk servers
func (c *AsteriskClient) ReloadPJSIP() []ServerResult {
return c.runOnAll("pjsip reload")
}
// ReloadDialplan sends a dialplan reload to all Asterisk servers
func (c *AsteriskClient) ReloadDialplan() []ServerResult {
return c.runOnAll("dialplan reload")
}
// ReloadAll reloads both PJSIP and dialplan on all servers
func (c *AsteriskClient) ReloadAll() []ServerResult {
results := c.runOnAll("core reload")
return results
}
// GetChannelCount returns active channel count from each server
func (c *AsteriskClient) GetChannelCount() []ServerResult {
return c.runOnAll("core show channels count")
}
// GetUptime returns uptime from each server
func (c *AsteriskClient) GetUptime() []ServerResult {
return c.runOnAll("core show uptime")
}
// ServerResult holds the result from an AMI command on a single server
type ServerResult struct {
Host string `json:"host"`
Success bool `json:"success"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
// runOnAll executes an AMI command on all Asterisk servers in parallel
func (c *AsteriskClient) runOnAll(command string) []ServerResult {
var wg sync.WaitGroup
results := make([]ServerResult, len(c.servers))
for i, srv := range c.servers {
wg.Add(1)
go func(idx int, server AsteriskServer) {
defer wg.Done()
results[idx] = c.execAMI(server, command)
}(i, srv)
}
wg.Wait()
return results
}
// execAMI connects to a single Asterisk AMI server and executes a command
func (c *AsteriskClient) execAMI(server AsteriskServer, command string) ServerResult {
addr := fmt.Sprintf("%s:%d", server.Host, server.AMIPort)
result := ServerResult{Host: server.Host}
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
result.Error = fmt.Sprintf("connection failed: %v", err)
c.logger.Warn().Str("host", server.Host).Err(err).Msg("AMI connection failed")
return result
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(conn)
// Read AMI banner
banner, err := reader.ReadString('\n')
if err != nil {
result.Error = fmt.Sprintf("failed to read banner: %v", err)
return result
}
if !strings.HasPrefix(banner, "Asterisk Call Manager") {
result.Error = fmt.Sprintf("unexpected banner: %s", strings.TrimSpace(banner))
return result
}
// Login
loginMsg := fmt.Sprintf("Action: Login\r\nUsername: %s\r\nSecret: %s\r\n\r\n",
c.user, c.secret)
if _, err := conn.Write([]byte(loginMsg)); err != nil {
result.Error = fmt.Sprintf("login write failed: %v", err)
return result
}
// Read login response
loginResp, err := readAMIResponse(reader)
if err != nil {
result.Error = fmt.Sprintf("login response failed: %v", err)
return result
}
if !strings.Contains(loginResp, "Success") {
result.Error = fmt.Sprintf("login failed: %s", loginResp)
return result
}
// Execute command
cmdMsg := fmt.Sprintf("Action: Command\r\nCommand: %s\r\n\r\n", command)
if _, err := conn.Write([]byte(cmdMsg)); err != nil {
result.Error = fmt.Sprintf("command write failed: %v", err)
return result
}
// Read command response
cmdResp, err := readAMIResponse(reader)
if err != nil {
result.Error = fmt.Sprintf("command response failed: %v", err)
return result
}
// Logoff
conn.Write([]byte("Action: Logoff\r\n\r\n"))
result.Success = true
result.Output = cmdResp
c.logger.Debug().Str("host", server.Host).Str("command", command).Msg("AMI command executed")
return result
}
// readAMIResponse reads a complete AMI response (terminated by blank line)
func readAMIResponse(reader *bufio.Reader) (string, error) {
var lines []string
for {
line, err := reader.ReadString('\n')
if err != nil {
return strings.Join(lines, "\n"), err
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
break
}
lines = append(lines, trimmed)
}
return strings.Join(lines, "\n"), nil
}
// Health checks connectivity to all Asterisk servers
func (c *AsteriskClient) Health() error {
for _, srv := range c.servers {
addr := fmt.Sprintf("%s:%d", srv.Host, srv.AMIPort)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return fmt.Errorf("asterisk %s unreachable: %w", srv.Host, err)
}
conn.Close()
}
return nil
}
// Servers returns the configured server list
func (c *AsteriskClient) Servers() []AsteriskServer {
return c.servers
}

View File

@@ -0,0 +1,67 @@
package client
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// CardDAVClient wraps a pgx connection pool for the sabredav database
type CardDAVClient struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewCardDAVClient creates a new CardDAV database client
func NewCardDAVClient(cfg config.CardDAVConfig, dsn string, logger zerolog.Logger) (*CardDAVClient, error) {
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("failed to parse carddav database config: %w", err)
}
poolConfig.MaxConns = 10
poolConfig.MinConns = 2
poolConfig.MaxConnLifetime = 1 * time.Hour
poolConfig.MaxConnIdleTime = 30 * time.Minute
poolConfig.HealthCheckPeriod = 1 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("failed to create carddav connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping carddav database: %w", err)
}
return &CardDAVClient{
pool: pool,
logger: logger.With().Str("client", "carddav").Logger(),
}, nil
}
// Pool returns the underlying connection pool
func (c *CardDAVClient) Pool() *pgxpool.Pool {
return c.pool
}
// Health checks the database connection
func (c *CardDAVClient) Health(ctx context.Context) error {
return c.pool.Ping(ctx)
}
// Close closes the connection pool
func (c *CardDAVClient) Close() {
if c.pool != nil {
c.pool.Close()
}
}

188
internal/client/ejbca.go Normal file
View File

@@ -0,0 +1,188 @@
package client
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// EJBCAClient is an mTLS HTTP client for the EJBCA REST API
type EJBCAClient struct {
baseURL string
client *http.Client
logger zerolog.Logger
}
// NewEJBCAClient creates a new EJBCA client with mTLS
func NewEJBCAClient(cfg config.EJBCAConfig, logger zerolog.Logger) (*EJBCAClient, error) {
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to load EJBCA client cert: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
if cfg.CAFile != "" {
caCert, err := os.ReadFile(cfg.CAFile)
if err != nil {
return nil, fmt.Errorf("failed to read EJBCA CA file: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg.RootCAs = pool
}
return &EJBCAClient{
baseURL: cfg.BaseURL,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
logger: logger.With().Str("component", "ejbca").Logger(),
}, nil
}
// EJBCACert represents a certificate from EJBCA
type EJBCACert struct {
SerialNumber string `json:"serial_number"`
SubjectDN string `json:"subject_dn"`
IssuerDN string `json:"issuer_dn"`
Status string `json:"status"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
CertificateData string `json:"certificate"`
CAName string `json:"ca_name,omitempty"`
}
// CertSearchRequest is the request body for searching certificates
type CertSearchRequest struct {
MaxResults int `json:"max_number_of_results"`
Criteria []CertSearchCriterion `json:"criteria"`
}
// CertSearchCriterion is a single search criterion
type CertSearchCriterion struct {
Property string `json:"property"`
Value string `json:"value"`
Operation string `json:"operation"`
}
// CertEnrollRequest is the request body for enrolling a certificate
type CertEnrollRequest struct {
CertificateRequest string `json:"certificate_request,omitempty"`
CertificateProfileName string `json:"certificate_profile_name"`
EndEntityProfileName string `json:"end_entity_profile_name"`
CAName string `json:"certificate_authority_name"`
Username string `json:"username"`
Password string `json:"password"`
IncludeChain bool `json:"include_chain"`
SubjectAltName string `json:"subject_alternative_name,omitempty"`
}
// CertRevokeRequest is the request body for revoking a certificate
type CertRevokeRequest struct {
IssuerDN string `json:"issuer_dn"`
SerialNumber string `json:"serial_number"`
Reason string `json:"reason"`
}
func (c *EJBCAClient) do(method, path string, body interface{}, result interface{}) error {
url := c.baseURL + path
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("EJBCA error %d: %s", resp.StatusCode, string(respBody))
}
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// SearchCertificates searches for certificates matching criteria
func (c *EJBCAClient) SearchCertificates(req *CertSearchRequest) ([]EJBCACert, error) {
var result struct {
Certificates []EJBCACert `json:"certificates"`
}
err := c.do("POST", "/ejbca/ejbca-rest-api/v1/certificate/search", req, &result)
if err != nil {
return nil, err
}
return result.Certificates, nil
}
// GetCertificate gets a certificate by serial number and issuer DN
func (c *EJBCAClient) GetCertificate(issuerDN, serialNumber string) (*EJBCACert, error) {
path := fmt.Sprintf("/ejbca/ejbca-rest-api/v1/certificate/%s/%s", issuerDN, serialNumber)
var cert EJBCACert
err := c.do("GET", path, nil, &cert)
if err != nil {
return nil, err
}
return &cert, nil
}
// EnrollCertificate requests a new certificate
func (c *EJBCAClient) EnrollCertificate(req *CertEnrollRequest) (*EJBCACert, error) {
var cert EJBCACert
err := c.do("POST", "/ejbca/ejbca-rest-api/v1/certificate/enrollkeystore", req, &cert)
if err != nil {
return nil, err
}
return &cert, nil
}
// RevokeCertificate revokes a certificate
func (c *EJBCAClient) RevokeCertificate(issuerDN, serialNumber, reason string) error {
path := fmt.Sprintf("/ejbca/ejbca-rest-api/v1/certificate/%s/%s/revoke?reason=%s",
issuerDN, serialNumber, reason)
return c.do("PUT", path, nil, nil)
}
// Health checks EJBCA connectivity
func (c *EJBCAClient) Health() error {
return c.do("GET", "/ejbca/ejbca-rest-api/v1/certificate/status", nil, nil)
}

View File

@@ -0,0 +1,162 @@
package client
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// HockeypuckClient implements the HKP (HTTP Keyserver Protocol) client
type HockeypuckClient struct {
servers []string
client *http.Client
logger zerolog.Logger
}
// NewHockeypuckClient creates a new Hockeypuck HKP client
func NewHockeypuckClient(cfg config.HockeypuckConfig, logger zerolog.Logger) *HockeypuckClient {
return &HockeypuckClient{
servers: cfg.Servers,
client: &http.Client{Timeout: 30 * time.Second},
logger: logger.With().Str("component", "hockeypuck").Logger(),
}
}
// SearchKeys searches for PGP keys by query string (email, name, or key ID)
func (c *HockeypuckClient) SearchKeys(query string) (string, error) {
params := url.Values{
"search": {query},
"op": {"index"},
"options": {"mr"},
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode())
resp, err := c.client.Get(u)
if err != nil {
c.logger.Warn().Err(err).Str("server", server).Msg("HKP search failed")
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode != http.StatusOK {
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
continue
}
return string(body), nil
}
return "", fmt.Errorf("all HKP servers failed")
}
// GetKey retrieves a PGP key by key ID
func (c *HockeypuckClient) GetKey(keyID string) (string, error) {
// Ensure keyID has 0x prefix
if !strings.HasPrefix(keyID, "0x") {
keyID = "0x" + keyID
}
params := url.Values{
"search": {keyID},
"op": {"get"},
"options": {"mr"},
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode())
resp, err := c.client.Get(u)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode != http.StatusOK {
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
continue
}
return string(body), nil
}
return "", fmt.Errorf("all HKP servers failed")
}
// UploadKey uploads a PGP public key
func (c *HockeypuckClient) UploadKey(armoredKey string) error {
form := url.Values{
"keytext": {armoredKey},
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/add", server)
resp, err := c.client.PostForm(u, form)
if err != nil {
c.logger.Warn().Err(err).Str("server", server).Msg("HKP upload failed")
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
return fmt.Errorf("failed to upload key to any HKP server")
}
// DeleteKey deletes a PGP key (Hockeypuck-specific API, not standard HKP)
func (c *HockeypuckClient) DeleteKey(keyID string) error {
if !strings.HasPrefix(keyID, "0x") {
keyID = "0x" + keyID
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/delete?search=%s", server, url.QueryEscape(keyID))
req, err := http.NewRequest("DELETE", u, nil)
if err != nil {
continue
}
resp, err := c.client.Do(req)
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
return fmt.Errorf("failed to delete key from any HKP server")
}
// Health checks HKP server connectivity
func (c *HockeypuckClient) Health() error {
for _, server := range c.servers {
resp, err := c.client.Get(server + "/pks/lookup?op=stats")
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
return fmt.Errorf("no HKP servers reachable")
}

125
internal/client/kamailio.go Normal file
View File

@@ -0,0 +1,125 @@
package client
import (
"fmt"
"sync"
"github.com/rs/zerolog"
"golang.org/x/crypto/ssh"
)
// KamailioClient manages SSH connections to Kamailio servers for kamcmd
type KamailioClient struct {
servers []string
sshUser string
sshKey []byte
logger zerolog.Logger
}
// NewKamailioClient creates a new Kamailio management client
func NewKamailioClient(servers []string, sshUser string, sshKey []byte, logger zerolog.Logger) *KamailioClient {
return &KamailioClient{
servers: servers,
sshUser: sshUser,
sshKey: sshKey,
logger: logger.With().Str("client", "kamailio").Logger(),
}
}
// ReloadDispatcher reloads the dispatcher module on all Kamailio servers
func (c *KamailioClient) ReloadDispatcher() []ServerResult {
return c.runOnAll("kamcmd dispatcher.reload")
}
// ReloadPermissions reloads address permissions on all Kamailio servers
func (c *KamailioClient) ReloadPermissions() []ServerResult {
return c.runOnAll("kamcmd permissions.addressReload")
}
// ReloadAll reloads dispatcher and permissions on all servers
func (c *KamailioClient) ReloadAll() []ServerResult {
results := make([]ServerResult, 0, len(c.servers)*2)
r1 := c.ReloadDispatcher()
r2 := c.ReloadPermissions()
results = append(results, r1...)
results = append(results, r2...)
return results
}
// runOnAll executes a command on all Kamailio servers in parallel
func (c *KamailioClient) runOnAll(command string) []ServerResult {
var wg sync.WaitGroup
results := make([]ServerResult, len(c.servers))
for i, srv := range c.servers {
wg.Add(1)
go func(idx int, host string) {
defer wg.Done()
results[idx] = c.execSSH(host, command)
}(i, srv)
}
wg.Wait()
return results
}
// execSSH connects via SSH and executes a command
func (c *KamailioClient) execSSH(host, command string) ServerResult {
result := ServerResult{Host: host}
signer, err := ssh.ParsePrivateKey(c.sshKey)
if err != nil {
result.Error = fmt.Sprintf("failed to parse SSH key: %v", err)
return result
}
config := &ssh.ClientConfig{
User: c.sshUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host+":22", config)
if err != nil {
result.Error = fmt.Sprintf("SSH connection failed: %v", err)
c.logger.Warn().Str("host", host).Err(err).Msg("Kamailio SSH connection failed")
return result
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
result.Error = fmt.Sprintf("SSH session failed: %v", err)
return result
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
result.Error = fmt.Sprintf("command failed: %v, output: %s", err, string(output))
return result
}
result.Success = true
result.Output = string(output)
c.logger.Debug().Str("host", host).Str("command", command).Msg("Kamailio command executed")
return result
}
// Health checks SSH connectivity to all Kamailio servers
func (c *KamailioClient) Health() error {
for _, srv := range c.servers {
r := c.execSSH(srv, "kamcmd core.uptime")
if !r.Success {
return fmt.Errorf("kamailio %s: %s", srv, r.Error)
}
}
return nil
}
// Servers returns the configured server list
func (c *KamailioClient) Servers() []string {
return c.servers
}

226
internal/client/ldap.go Normal file
View File

@@ -0,0 +1,226 @@
package client
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// LDAPClient manages a pool of LDAP connections
type LDAPClient struct {
cfg config.LDAPConfig
pool chan *ldap.Conn
mu sync.Mutex
logger zerolog.Logger
}
// NewLDAPClient creates a new LDAP client with a connection pool
func NewLDAPClient(cfg config.LDAPConfig, logger zerolog.Logger) (*LDAPClient, error) {
if len(cfg.Servers) == 0 {
return nil, fmt.Errorf("no LDAP servers configured")
}
c := &LDAPClient{
cfg: cfg,
pool: make(chan *ldap.Conn, cfg.PoolSize),
logger: logger.With().Str("component", "ldap").Logger(),
}
// Pre-fill pool with connections
for i := 0; i < cfg.PoolSize; i++ {
conn, err := c.connect()
if err != nil {
c.logger.Warn().Err(err).Int("index", i).Msg("failed to create initial LDAP connection")
continue
}
c.pool <- conn
}
return c, nil
}
func (c *LDAPClient) connect() (*ldap.Conn, error) {
var lastErr error
for _, server := range c.cfg.Servers {
var conn *ldap.Conn
var err error
if c.cfg.UseTLS {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if c.cfg.CAFile != "" {
caCert, err := os.ReadFile(c.cfg.CAFile)
if err != nil {
lastErr = fmt.Errorf("failed to read CA file: %w", err)
continue
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg.RootCAs = pool
}
conn, err = ldap.DialURL(server, ldap.DialWithTLSConfig(tlsCfg))
} else {
conn, err = ldap.DialURL(server)
}
if err != nil {
lastErr = fmt.Errorf("failed to connect to %s: %w", server, err)
continue
}
conn.SetTimeout(10 * time.Second)
if err := conn.Bind(c.cfg.BindDN, c.cfg.BindPass); err != nil {
conn.Close()
lastErr = fmt.Errorf("failed to bind to %s: %w", server, err)
continue
}
return conn, nil
}
return nil, fmt.Errorf("all LDAP servers failed: %w", lastErr)
}
// Acquire gets a connection from the pool, creating one if needed
func (c *LDAPClient) Acquire() (*ldap.Conn, error) {
select {
case conn := <-c.pool:
// Test the connection with a no-op search
_, err := conn.Search(&ldap.SearchRequest{
BaseDN: "",
Scope: ldap.ScopeBaseObject,
Filter: "(objectClass=*)",
SizeLimit: 1,
})
if err != nil {
conn.Close()
return c.connect()
}
return conn, nil
default:
return c.connect()
}
}
// Release returns a connection to the pool
func (c *LDAPClient) Release(conn *ldap.Conn) {
if conn == nil {
return
}
select {
case c.pool <- conn:
default:
conn.Close()
}
}
// Close closes all pooled connections
func (c *LDAPClient) Close() {
close(c.pool)
for conn := range c.pool {
conn.Close()
}
}
// Health checks LDAP connectivity
func (c *LDAPClient) Health() error {
conn, err := c.Acquire()
if err != nil {
return err
}
defer c.Release(conn)
return nil
}
// Search executes an LDAP search
func (c *LDAPClient) Search(baseDN, filter string, attrs []string, sizeLimit int) ([]*ldap.Entry, error) {
conn, err := c.Acquire()
if err != nil {
return nil, fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
sr, err := conn.Search(&ldap.SearchRequest{
BaseDN: baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: attrs,
SizeLimit: sizeLimit,
})
if err != nil {
// FreeIPA returns SizeLimitExceeded with partial results
if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) && sr != nil {
return sr.Entries, nil
}
return nil, fmt.Errorf("LDAP search failed: %w", err)
}
return sr.Entries, nil
}
// SearchOne executes an LDAP search expecting exactly one result
func (c *LDAPClient) SearchOne(baseDN, filter string, attrs []string) (*ldap.Entry, error) {
entries, err := c.Search(baseDN, filter, attrs, 1)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
return entries[0], nil
}
// Add adds an LDAP entry
func (c *LDAPClient) Add(req *ldap.AddRequest) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Add(req)
}
// Modify modifies an LDAP entry
func (c *LDAPClient) Modify(req *ldap.ModifyRequest) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Modify(req)
}
// Delete deletes an LDAP entry
func (c *LDAPClient) Delete(dn string) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Del(&ldap.DelRequest{DN: dn})
}
// PasswordModify changes a user's password
func (c *LDAPClient) PasswordModify(userDN, newPassword string) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
_, err = conn.PasswordModify(&ldap.PasswordModifyRequest{
UserIdentity: userDN,
NewPassword: newPassword,
})
return err
}

180
internal/client/powerdns.go Normal file
View File

@@ -0,0 +1,180 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// PowerDNSClient is an HTTP client for the PowerDNS API
type PowerDNSClient struct {
baseURL string
apiKey string
serverID string
client *http.Client
logger zerolog.Logger
}
// NewPowerDNSClient creates a new PowerDNS client
func NewPowerDNSClient(cfg config.PowerDNSConfig, logger zerolog.Logger) *PowerDNSClient {
return &PowerDNSClient{
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
serverID: cfg.ServerID,
client: &http.Client{Timeout: 30 * time.Second},
logger: logger.With().Str("component", "powerdns").Logger(),
}
}
// RRSet represents a PowerDNS resource record set
type RRSet struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
ChangeType string `json:"changetype,omitempty"`
Records []Record `json:"records"`
Comments []Comment `json:"comments,omitempty"`
}
// Record represents a single DNS record
type Record struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
// Comment represents a comment on an RRSet
type Comment struct {
Content string `json:"content"`
Account string `json:"account"`
ModifiedAt int64 `json:"modified_at"`
}
// Zone represents a PowerDNS zone
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
DNSSec bool `json:"dnssec"`
Serial int64 `json:"serial"`
NotifiedSerial int64 `json:"notified_serial"`
SOAEdit string `json:"soa_edit,omitempty"`
SOAEditAPI string `json:"soa_edit_api,omitempty"`
RRSets []RRSet `json:"rrsets,omitempty"`
Nameservers []string `json:"nameservers,omitempty"`
Masters []string `json:"masters,omitempty"`
}
// ZoneCreate is the request body for creating a zone
type ZoneCreate struct {
Name string `json:"name"`
Kind string `json:"kind"`
Nameservers []string `json:"nameservers"`
Masters []string `json:"masters,omitempty"`
}
func (c *PowerDNSClient) do(method, path string, body interface{}, result interface{}) error {
url := fmt.Sprintf("%s/api/v1/servers/%s%s", c.baseURL, c.serverID, path)
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("PowerDNS error %d: %s", resp.StatusCode, string(respBody))
}
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// ListZones lists all zones
func (c *PowerDNSClient) ListZones() ([]Zone, error) {
var zones []Zone
err := c.do("GET", "/zones", nil, &zones)
return zones, err
}
// GetZone gets a zone by ID with RRSets
func (c *PowerDNSClient) GetZone(zoneID string) (*Zone, error) {
var zone Zone
err := c.do("GET", "/zones/"+zoneID, nil, &zone)
if err != nil {
return nil, err
}
return &zone, nil
}
// CreateZone creates a new zone
func (c *PowerDNSClient) CreateZone(zone *ZoneCreate) (*Zone, error) {
var result Zone
err := c.do("POST", "/zones", zone, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// UpdateZone updates zone metadata (PATCH to /zones/:id is for metadata only)
func (c *PowerDNSClient) UpdateZone(zoneID string, data map[string]interface{}) error {
return c.do("PUT", "/zones/"+zoneID, data, nil)
}
// DeleteZone deletes a zone
func (c *PowerDNSClient) DeleteZone(zoneID string) error {
return c.do("DELETE", "/zones/"+zoneID, nil, nil)
}
// NotifyZone sends NOTIFY to slaves
func (c *PowerDNSClient) NotifyZone(zoneID string) error {
return c.do("PUT", "/zones/"+zoneID+"/notify", nil, nil)
}
// PatchRRSets patches record sets in a zone (create, update, or delete)
func (c *PowerDNSClient) PatchRRSets(zoneID string, rrsets []RRSet) error {
body := map[string]interface{}{
"rrsets": rrsets,
}
return c.do("PATCH", "/zones/"+zoneID, body, nil)
}
// Health checks PowerDNS connectivity
func (c *PowerDNSClient) Health() error {
_, err := c.ListZones()
return err
}

377
internal/config/config.go Normal file
View File

@@ -0,0 +1,377 @@
package config
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Databases map[string]DatabaseConfig `yaml:"databases"`
TLS TLSConfig `yaml:"tls"`
LDAP LDAPConfig `yaml:"ldap"`
PowerDNS PowerDNSConfig `yaml:"powerdns"`
EJBCA EJBCAConfig `yaml:"ejbca"`
Hockeypuck HockeypuckConfig `yaml:"hockeypuck"`
Infisical InfisicalConfig `yaml:"infisical"`
CardDAV CardDAVConfig `yaml:"carddav"`
Asterisk AsteriskConfig `yaml:"asterisk"`
Kamailio KamailioConfig `yaml:"kamailio"`
Auth AuthConfig `yaml:"auth"`
Logging LoggingConfig `yaml:"logging"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
ReadTimeout time.Duration `yaml:"readTimeout"`
WriteTimeout time.Duration `yaml:"writeTimeout"`
}
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Database string `yaml:"database"`
Schema string `yaml:"schema"`
User string `yaml:"user"`
Password string `yaml:"password"`
SSLMode string `yaml:"sslMode"`
MaxConns int `yaml:"maxConns"`
MinConns int `yaml:"minConns"`
}
type TLSConfig struct {
CertFile string `yaml:"certFile"`
KeyFile string `yaml:"keyFile"`
CAFile string `yaml:"caFile"`
}
type LDAPConfig struct {
Servers []string `yaml:"servers"`
BaseDN string `yaml:"baseDn"`
BindDN string `yaml:"bindDn"`
BindPass string `yaml:"bindPassword"`
PoolSize int `yaml:"poolSize"`
UseTLS bool `yaml:"useTls"`
CAFile string `yaml:"caFile"`
}
type PowerDNSConfig struct {
BaseURL string `yaml:"baseUrl"`
APIKey string `yaml:"apiKey"`
ServerID string `yaml:"serverId"`
}
type EJBCAConfig struct {
BaseURL string `yaml:"baseUrl"`
CertFile string `yaml:"certFile"`
KeyFile string `yaml:"keyFile"`
CAFile string `yaml:"caFile"`
}
type HockeypuckConfig struct {
Servers []string `yaml:"servers"`
}
type CardDAVConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Database string `yaml:"database"`
User string `yaml:"user"`
Password string `yaml:"password"`
SSLMode string `yaml:"sslMode"`
}
type AsteriskServerConfig struct {
Host string `yaml:"host"`
AMIPort int `yaml:"amiPort"`
}
type AsteriskConfig struct {
Servers []AsteriskServerConfig `yaml:"servers"`
AMIUser string `yaml:"amiUser"`
AMISecret string `yaml:"amiSecret"`
}
type KamailioConfig struct {
Servers []string `yaml:"servers"`
SSHUser string `yaml:"sshUser"`
SSHKey string `yaml:"sshKeyFile"`
}
type InfisicalConfig struct {
Host string `yaml:"host"`
ProjectID string `yaml:"projectId"`
Environment string `yaml:"environment"`
TokenFile string `yaml:"tokenFile"`
SecretPath string `yaml:"secretPath"`
}
type AuthConfig struct {
APIKeys []string `yaml:"apiKeys"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
cfg.setDefaults()
return &cfg, nil
}
func (c *Config) setDefaults() {
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
}
if c.Server.Port == 0 {
c.Server.Port = 8443
}
if c.Server.ReadTimeout == 0 {
c.Server.ReadTimeout = 30 * time.Second
}
if c.Server.WriteTimeout == 0 {
c.Server.WriteTimeout = 30 * time.Second
}
// Legacy single database config
if c.Database.Port == 0 {
c.Database.Port = 5432
}
if c.Database.SSLMode == "" {
c.Database.SSLMode = "require"
}
if c.Database.MaxConns == 0 {
c.Database.MaxConns = 25
}
if c.Database.MinConns == 0 {
c.Database.MinConns = 5
}
// Multi-database defaults
if c.Databases == nil {
c.Databases = make(map[string]DatabaseConfig)
}
for name, db := range c.Databases {
if db.Port == 0 {
db.Port = 5432
}
if db.SSLMode == "" {
db.SSLMode = c.Database.SSLMode
}
if db.Host == "" {
db.Host = c.Database.Host
}
if db.MaxConns == 0 {
db.MaxConns = 10
}
if db.MinConns == 0 {
db.MinConns = 2
}
c.Databases[name] = db
}
if c.LDAP.PoolSize == 0 {
c.LDAP.PoolSize = 10
}
if c.PowerDNS.ServerID == "" {
c.PowerDNS.ServerID = "localhost"
}
if c.CardDAV.Port == 0 {
c.CardDAV.Port = 5432
}
if c.CardDAV.SSLMode == "" {
c.CardDAV.SSLMode = "disable"
}
if c.Asterisk.AMIUser == "" {
c.Asterisk.AMIUser = "gsc-ops-api"
}
for i := range c.Asterisk.Servers {
if c.Asterisk.Servers[i].AMIPort == 0 {
c.Asterisk.Servers[i].AMIPort = 5038
}
}
if c.Kamailio.SSHUser == "" {
c.Kamailio.SSHUser = "root"
}
if c.Logging.Level == "" {
c.Logging.Level = "info"
}
if c.Logging.Format == "" {
c.Logging.Format = "json"
}
if c.Infisical.SecretPath == "" {
c.Infisical.SecretPath = "/gsc-ops-api"
}
}
// LoadSecretsFromInfisical fetches secrets from the Infisical API
func (c *Config) LoadSecretsFromInfisical() error {
if c.Infisical.Host == "" {
return nil
}
tokenFile := c.Infisical.TokenFile
if tokenFile == "" {
tokenFile = "/etc/gsc-ops-api/.infisical"
}
tokenData, err := os.ReadFile(tokenFile)
if err != nil {
return fmt.Errorf("failed to read Infisical token: %w", err)
}
token := strings.TrimSpace(string(tokenData))
// Legacy single database credentials
if v, err := c.fetchSecret(token, "GSC_OPS_DB_USER"); err == nil && v != "" {
c.Database.User = v
}
if v, err := c.fetchSecret(token, "GSC_OPS_DB_PASSWORD"); err == nil && v != "" {
c.Database.Password = v
}
// Per-database credentials: inherit from primary DB user/password
for name, db := range c.Databases {
if db.User == "" {
db.User = c.Database.User
}
if db.Password == "" {
db.Password = c.Database.Password
}
c.Databases[name] = db
}
// LDAP credentials
if v, err := c.fetchSecret(token, "GSC_OPS_LDAP_BIND_DN"); err == nil && v != "" {
c.LDAP.BindDN = v
}
if v, err := c.fetchSecret(token, "GSC_OPS_LDAP_BIND_PASSWORD"); err == nil && v != "" {
c.LDAP.BindPass = v
}
// PowerDNS API key
if v, err := c.fetchSecret(token, "GSC_OPS_PDNS_API_KEY"); err == nil && v != "" {
c.PowerDNS.APIKey = v
}
// CardDAV DB password
if v, err := c.fetchSecret(token, "GSC_OPS_CARDDAV_DB_PASSWORD"); err == nil && v != "" {
c.CardDAV.Password = v
}
// API keys for authorized clients
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_SKILL_SERVER"); err == nil && v != "" {
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
}
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_VOICE_AGENT"); err == nil && v != "" {
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
}
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_GSC_MY"); err == nil && v != "" {
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
}
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_SYNAPSE_HUB"); err == nil && v != "" {
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
}
// Asterisk AMI secret
if v, err := c.fetchSecret(token, "GSC_OPS_ASTERISK_AMI_SECRET"); err == nil && v != "" {
c.Asterisk.AMISecret = v
}
return nil
}
func (c *Config) fetchSecret(token, secretName string) (string, error) {
url := fmt.Sprintf("%s/api/v3/secrets/raw/%s?workspaceId=%s&environment=%s&secretPath=%s",
c.Infisical.Host, secretName, c.Infisical.ProjectID, c.Infisical.Environment, c.Infisical.SecretPath)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Infisical returned status %d for %s", resp.StatusCode, secretName)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result struct {
Secret struct {
SecretValue string `json:"secretValue"`
} `json:"secret"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
return result.Secret.SecretValue, nil
}
// DatabaseDSN returns the legacy database connection string
func (c *Config) DatabaseDSN() string {
return fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
c.Database.Host, c.Database.Port, c.Database.Database,
c.Database.User, c.Database.Password, c.Database.SSLMode)
}
// NamedDatabaseDSN returns the connection string for a named database
func (c *Config) NamedDatabaseDSN(name string) string {
db, ok := c.Databases[name]
if !ok {
return c.DatabaseDSN()
}
dsn := fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
db.Host, db.Port, db.Database,
db.User, db.Password, db.SSLMode)
if db.Schema != "" {
dsn += fmt.Sprintf(" search_path=%s,public", db.Schema)
}
return dsn
}
// HasDatabase returns true if a named database is configured
func (c *Config) HasDatabase(name string) bool {
_, ok := c.Databases[name]
return ok
}
// CardDAVDSN returns the CardDAV database connection string
func (c *Config) CardDAVDSN() string {
return fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
c.CardDAV.Host, c.CardDAV.Port, c.CardDAV.Database,
c.CardDAV.User, c.CardDAV.Password, c.CardDAV.SSLMode)
}

91
internal/database/db.go Normal file
View File

@@ -0,0 +1,91 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/gosec/gsc-ops-api/internal/config"
)
// DB wraps the database connection pool
type DB struct {
pool *pgxpool.Pool
}
// New creates a new database connection pool using the legacy config
func New(cfg *config.Config) (*DB, error) {
return NewFromDSN(cfg.DatabaseDSN(), cfg.Database.MaxConns, cfg.Database.MinConns)
}
// NewNamed creates a new database connection pool for a named database
func NewNamed(cfg *config.Config, name string) (*DB, error) {
db, ok := cfg.Databases[name]
if !ok {
return nil, fmt.Errorf("database %q not configured", name)
}
return NewFromDSN(cfg.NamedDatabaseDSN(name), db.MaxConns, db.MinConns)
}
// NewFromDSN creates a new database connection pool from a DSN string
func NewFromDSN(dsn string, maxConns, minConns int) (*DB, error) {
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("failed to parse database config: %w", err)
}
poolConfig.MaxConns = int32(maxConns)
poolConfig.MinConns = int32(minConns)
poolConfig.MaxConnLifetime = 1 * time.Hour
poolConfig.MaxConnIdleTime = 30 * time.Minute
poolConfig.HealthCheckPeriod = 1 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DB{pool: pool}, nil
}
func (db *DB) Close() {
if db.pool != nil {
db.pool.Close()
}
}
func (db *DB) Pool() *pgxpool.Pool {
return db.pool
}
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
return db.pool.Query(ctx, sql, args...)
}
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
return db.pool.QueryRow(ctx, sql, args...)
}
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
return db.pool.Exec(ctx, sql, args...)
}
func (db *DB) Health(ctx context.Context) error {
return db.pool.Ping(ctx)
}
func (db *DB) Stats() *pgxpool.Stat {
return db.pool.Stat()
}

330
internal/handler/carddav.go Normal file
View File

@@ -0,0 +1,330 @@
package handler
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// CardDAVHandler handles CardDAV endpoints
type CardDAVHandler struct {
svc *service.CardDAVService
}
// NewCardDAVHandler creates a new CardDAV handler
func NewCardDAVHandler(svc *service.CardDAVService) *CardDAVHandler {
return &CardDAVHandler{svc: svc}
}
// --- Principals ---
// ListPrincipals handles GET /api/v1/carddav/principals
func (h *CardDAVHandler) ListPrincipals(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
principals, err := h.svc.ListPrincipals(c.Context())
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(principals, reqID))
}
// GetPrincipal handles GET /api/v1/carddav/principals/:username
func (h *CardDAVHandler) GetPrincipal(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
username := c.Params("username")
principal, err := h.svc.GetPrincipal(c.Context(), username)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if principal == nil {
apiErr := types.NewNotFound("Principal not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(principal, reqID))
}
// CreatePrincipal handles POST /api/v1/carddav/principals
func (h *CardDAVHandler) CreatePrincipal(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.CardDAVPrincipalCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Username == "" {
apiErr := types.NewValidation("username is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
principal, err := h.svc.CreatePrincipal(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(principal, reqID))
}
// DeletePrincipal handles DELETE /api/v1/carddav/principals/:username
func (h *CardDAVHandler) DeletePrincipal(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
username := c.Params("username")
if err := h.svc.DeletePrincipal(c.Context(), username); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"username": username, "deleted": true}, reqID))
}
// --- Address Books ---
// ListAddressBooks handles GET /api/v1/carddav/addressbooks
func (h *CardDAVHandler) ListAddressBooks(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
principal := c.Query("principal")
books, err := h.svc.ListAddressBooks(c.Context(), principal)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(books, reqID))
}
// GetAddressBook handles GET /api/v1/carddav/addressbooks/:id
func (h *CardDAVHandler) GetAddressBook(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
book, err := h.svc.GetAddressBook(c.Context(), id)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if book == nil {
apiErr := types.NewNotFound("Address book not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(book, reqID))
}
// CreateAddressBook handles POST /api/v1/carddav/addressbooks
func (h *CardDAVHandler) CreateAddressBook(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.AddressBookCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.PrincipalURI == "" || req.DisplayName == "" || req.URI == "" {
apiErr := types.NewValidation("principalUri, displayName, and uri are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
book, err := h.svc.CreateAddressBook(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(book, reqID))
}
// UpdateAddressBook handles PUT /api/v1/carddav/addressbooks/:id
func (h *CardDAVHandler) UpdateAddressBook(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.AddressBookUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
book, err := h.svc.UpdateAddressBook(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if book == nil {
apiErr := types.NewNotFound("Address book not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(book, reqID))
}
// DeleteAddressBook handles DELETE /api/v1/carddav/addressbooks/:id
func (h *CardDAVHandler) DeleteAddressBook(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteAddressBook(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
}
// --- Contacts ---
// ListContacts handles GET /api/v1/carddav/addressbooks/:id/contacts
func (h *CardDAVHandler) ListContacts(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
contacts, err := h.svc.ListContacts(c.Context(), id)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(contacts, reqID))
}
// GetContact handles GET /api/v1/carddav/addressbooks/:id/contacts/:uri
func (h *CardDAVHandler) GetContact(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
uri := c.Params("uri")
contact, err := h.svc.GetContact(c.Context(), id, uri)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if contact == nil {
apiErr := types.NewNotFound("Contact not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(contact, reqID))
}
// CreateContact handles POST /api/v1/carddav/addressbooks/:id/contacts
func (h *CardDAVHandler) CreateContact(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.ContactCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.URI == "" || req.CardData == "" {
apiErr := types.NewValidation("uri and cardData are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
contact, err := h.svc.CreateContact(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(contact, reqID))
}
// UpdateContact handles PUT /api/v1/carddav/addressbooks/:id/contacts/:uri
func (h *CardDAVHandler) UpdateContact(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
uri := c.Params("uri")
var req types.ContactUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.CardData == "" {
apiErr := types.NewValidation("cardData is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
contact, err := h.svc.UpdateContact(c.Context(), id, uri, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if contact == nil {
apiErr := types.NewNotFound("Contact not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(contact, reqID))
}
// DeleteContact handles DELETE /api/v1/carddav/addressbooks/:id/contacts/:uri
func (h *CardDAVHandler) DeleteContact(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid address book ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
uri := c.Params("uri")
if err := h.svc.DeleteContact(c.Context(), id, uri); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"addressbookId": id, "uri": uri, "deleted": true}, reqID))
}

140
internal/handler/certs.go Normal file
View File

@@ -0,0 +1,140 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// CertHandler handles certificate endpoints
type CertHandler struct {
svc *service.CertificateService
}
// NewCertHandler creates a new certificate handler
func NewCertHandler(svc *service.CertificateService) *CertHandler {
return &CertHandler{svc: svc}
}
// List handles GET /api/v1/certs
func (h *CertHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
search := c.Query("search")
limit := c.QueryInt("limit", 50)
certs, err := h.svc.ListCertificates(search, limit)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(certs, reqID))
}
// Get handles GET /api/v1/certs/:serialNumber
func (h *CertHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
serialNumber := c.Params("serialNumber")
issuerDN := c.Query("issuerDn")
if issuerDN == "" {
apiErr := types.NewValidation("issuerDn query parameter is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
cert, err := h.svc.GetCertificate(serialNumber, issuerDN)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(cert, reqID))
}
// Request handles POST /api/v1/certs/request
func (h *CertHandler) Request(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.CertRequest
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.SubjectDN == "" || req.CAName == "" || req.CertProfileName == "" || req.EndEntityName == "" {
apiErr := types.NewValidation("subjectDn, caName, certProfileName, and endEntityName are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
cert, err := h.svc.RequestCertificate(&req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(cert, reqID))
}
// Renew handles POST /api/v1/certs/:serialNumber/renew
func (h *CertHandler) Renew(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
serialNumber := c.Params("serialNumber")
// For renewal, we re-request with the same parameters
// The caller should provide the original cert's issuer DN
issuerDN := c.Query("issuerDn")
if issuerDN == "" {
apiErr := types.NewValidation("issuerDn query parameter is required for renewal")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
// Get the existing cert to extract parameters
existing, err := h.svc.GetCertificate(serialNumber, issuerDN)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
// Re-request with same subject
cert, err := h.svc.RequestCertificate(&types.CertRequest{
SubjectDN: existing.SubjectDN,
CAName: existing.CAName,
CertProfileName: "SERVER",
EndEntityName: existing.SubjectDN,
})
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(cert, reqID))
}
// Revoke handles POST /api/v1/certs/:serialNumber/revoke
func (h *CertHandler) Revoke(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
serialNumber := c.Params("serialNumber")
var req types.CertRevoke
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.IssuerDN == "" {
apiErr := types.NewValidation("issuerDn is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.RevokeCertificate(serialNumber, &req); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{
"serialNumber": serialNumber,
"revoked": true,
}, reqID))
}

View File

@@ -0,0 +1,126 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// DBTenantHandler handles database tenant endpoints
type DBTenantHandler struct {
svc *service.DatabaseService
}
// NewDBTenantHandler creates a new DB tenant handler
func NewDBTenantHandler(svc *service.DatabaseService) *DBTenantHandler {
return &DBTenantHandler{svc: svc}
}
// List handles GET /api/v1/db/tenants
func (h *DBTenantHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
Status: c.Query("status"),
}
tenants, total, err := h.svc.ListTenants(c.Context(), params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(tenants, total, params.Limit, params.Offset, reqID))
}
// Get handles GET /api/v1/db/tenants/:id
func (h *DBTenantHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid tenant ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenant, err := h.svc.GetTenant(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("Tenant not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(tenant, reqID))
}
// Create handles POST /api/v1/db/tenants
func (h *DBTenantHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.TenantCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Name == "" || req.Code == "" || req.CustomerID == uuid.Nil {
apiErr := types.NewValidation("customerId, code, and name are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenant, err := h.svc.CreateTenant(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(tenant, reqID))
}
// Update handles PUT /api/v1/db/tenants/:id
func (h *DBTenantHandler) Update(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid tenant ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.TenantUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenant, err := h.svc.UpdateTenant(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(tenant, reqID))
}
// Delete handles DELETE /api/v1/db/tenants/:id (soft delete)
func (h *DBTenantHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid tenant ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.SoftDeleteTenant(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
}

View File

@@ -0,0 +1,126 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// DBUserHandler handles database user endpoints
type DBUserHandler struct {
svc *service.DatabaseService
}
// NewDBUserHandler creates a new DB user handler
func NewDBUserHandler(svc *service.DatabaseService) *DBUserHandler {
return &DBUserHandler{svc: svc}
}
// List handles GET /api/v1/db/users
func (h *DBUserHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
Status: c.Query("status"),
}
users, total, err := h.svc.ListUsers(c.Context(), params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(users, total, params.Limit, params.Offset, reqID))
}
// Get handles GET /api/v1/db/users/:id
func (h *DBUserHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid user ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
user, err := h.svc.GetUser(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("User not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(user, reqID))
}
// Create handles POST /api/v1/db/users
func (h *DBUserHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.DBUserCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.GscSID == "" {
apiErr := types.NewValidation("gscsid is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
user, err := h.svc.CreateUser(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(user, reqID))
}
// Update handles PUT /api/v1/db/users/:id
func (h *DBUserHandler) Update(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid user ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.DBUserUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
user, err := h.svc.UpdateUser(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(user, reqID))
}
// Delete handles DELETE /api/v1/db/users/:id (deactivate)
func (h *DBUserHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid user ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeactivateUser(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deactivated": true}, reqID))
}

View File

@@ -0,0 +1,167 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// DNSRecordHandler handles DNS record endpoints
type DNSRecordHandler struct {
svc *service.DNSService
}
// NewDNSRecordHandler creates a new DNS record handler
func NewDNSRecordHandler(svc *service.DNSService) *DNSRecordHandler {
return &DNSRecordHandler{svc: svc}
}
// List handles GET /api/v1/dns/zones/:zoneId/records
func (h *DNSRecordHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
records, err := h.svc.ListRecords(zoneID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(records, reqID))
}
// Create handles POST /api/v1/dns/zones/:zoneId/records
func (h *DNSRecordHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
var changes []types.DNSRecordChange
if err := c.BodyParser(&changes); err != nil {
// Try single change
var single types.DNSRecordChange
if err2 := c.BodyParser(&single); err2 != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
changes = []types.DNSRecordChange{single}
}
// Set changetype to REPLACE for creates
for i := range changes {
if changes[i].ChangeType == "" {
changes[i].ChangeType = "REPLACE"
}
}
if err := h.svc.ChangeRecords(zoneID, changes); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(fiber.Map{
"zoneId": zoneID,
"changes": len(changes),
}, reqID))
}
// Replace handles PUT /api/v1/dns/zones/:zoneId/records
func (h *DNSRecordHandler) Replace(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
var changes []types.DNSRecordChange
if err := c.BodyParser(&changes); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
for i := range changes {
changes[i].ChangeType = "REPLACE"
}
if err := h.svc.ChangeRecords(zoneID, changes); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{
"zoneId": zoneID,
"replaced": len(changes),
}, reqID))
}
// Delete handles DELETE /api/v1/dns/zones/:zoneId/records
func (h *DNSRecordHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
var changes []types.DNSRecordChange
if err := c.BodyParser(&changes); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
for i := range changes {
changes[i].ChangeType = "DELETE"
}
if err := h.svc.ChangeRecords(zoneID, changes); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{
"zoneId": zoneID,
"deleted": len(changes),
}, reqID))
}
// DomainSetup handles POST /api/v1/dns/domains/setup
func (h *DNSRecordHandler) DomainSetup(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.DomainSetup
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Domain == "" {
apiErr := types.NewValidation("domain is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
zone, err := h.svc.SetupDomain(&req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(zone, reqID))
}
// DomainVerify handles POST /api/v1/dns/domains/verify
func (h *DNSRecordHandler) DomainVerify(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.DomainVerify
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Domain == "" {
apiErr := types.NewValidation("domain is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
result, err := h.svc.VerifyDomain(req.Domain)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(result, reqID))
}

View File

@@ -0,0 +1,115 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// DNSZoneHandler handles DNS zone endpoints
type DNSZoneHandler struct {
svc *service.DNSService
}
// NewDNSZoneHandler creates a new DNS zone handler
func NewDNSZoneHandler(svc *service.DNSService) *DNSZoneHandler {
return &DNSZoneHandler{svc: svc}
}
// List handles GET /api/v1/dns/zones
func (h *DNSZoneHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zones, err := h.svc.ListZones()
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(zones, reqID))
}
// Get handles GET /api/v1/dns/zones/:zoneId
func (h *DNSZoneHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
zone, err := h.svc.GetZone(zoneID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(zone, reqID))
}
// Create handles POST /api/v1/dns/zones
func (h *DNSZoneHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.DNSZoneCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Name == "" {
apiErr := types.NewValidation("name is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
zone, err := h.svc.CreateZone(&req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(zone, reqID))
}
// Update handles PUT /api/v1/dns/zones/:zoneId
func (h *DNSZoneHandler) Update(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
var req types.DNSZoneUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.UpdateZone(zoneID, &req); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": zoneID, "updated": true}, reqID))
}
// Delete handles DELETE /api/v1/dns/zones/:zoneId
func (h *DNSZoneHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
if err := h.svc.DeleteZone(zoneID); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": zoneID, "deleted": true}, reqID))
}
// Notify handles POST /api/v1/dns/zones/:zoneId/notify
func (h *DNSZoneHandler) Notify(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
zoneID := c.Params("zoneId")
if err := h.svc.NotifyZone(zoneID); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": zoneID, "notified": true}, reqID))
}

View File

@@ -0,0 +1,94 @@
package handler
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/internal/database"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// HealthHandler handles health check endpoints
type HealthHandler struct {
db *database.DB
ldap *client.LDAPClient
pdns *client.PowerDNSClient
carddav *client.CardDAVClient
}
// NewHealthHandler creates a new health handler
func NewHealthHandler(db *database.DB, ldap *client.LDAPClient, pdns *client.PowerDNSClient, carddav *client.CardDAVClient) *HealthHandler {
return &HealthHandler{db: db, ldap: ldap, pdns: pdns, carddav: carddav}
}
// Liveness returns 200 if the server is running
func (h *HealthHandler) Liveness(c *fiber.Ctx) error {
return c.JSON(types.NewDataResponse(fiber.Map{
"status": "ok",
"time": time.Now().UTC(),
}, middleware.GetRequestID(c)))
}
// Readiness checks all backend dependencies
func (h *HealthHandler) Readiness(c *fiber.Ctx) error {
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
defer cancel()
checks := make(map[string]string)
allOK := true
// Database
if err := h.db.Health(ctx); err != nil {
checks["database"] = "error: " + err.Error()
allOK = false
} else {
checks["database"] = "ok"
}
// LDAP
if h.ldap != nil {
if err := h.ldap.Health(); err != nil {
checks["ldap"] = "error: " + err.Error()
allOK = false
} else {
checks["ldap"] = "ok"
}
}
// PowerDNS
if h.pdns != nil {
if err := h.pdns.Health(); err != nil {
checks["powerdns"] = "error: " + err.Error()
allOK = false
} else {
checks["powerdns"] = "ok"
}
}
// CardDAV
if h.carddav != nil {
if err := h.carddav.Health(ctx); err != nil {
checks["carddav"] = "error: " + err.Error()
allOK = false
} else {
checks["carddav"] = "ok"
}
}
status := "ok"
httpStatus := fiber.StatusOK
if !allOK {
status = "degraded"
httpStatus = fiber.StatusServiceUnavailable
}
return c.Status(httpStatus).JSON(types.NewDataResponse(fiber.Map{
"status": status,
"checks": checks,
"time": time.Now().UTC(),
}, middleware.GetRequestID(c)))
}

View File

@@ -0,0 +1,178 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/schema"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// LDAPEntityHandler handles generic LDAP entity endpoints
type LDAPEntityHandler struct {
svc *service.LDAPEntityService
registry *schema.Registry
}
// NewLDAPEntityHandler creates a new entity handler
func NewLDAPEntityHandler(svc *service.LDAPEntityService, registry *schema.Registry) *LDAPEntityHandler {
return &LDAPEntityHandler{svc: svc, registry: registry}
}
// ListTypes handles GET /api/v1/ldap/entities — list available entity types
func (h *LDAPEntityHandler) ListTypes(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
allTypes := h.registry.AllEntityTypes()
result := make([]fiber.Map, 0, len(allTypes))
for name, et := range allTypes {
result = append(result, fiber.Map{
"name": name,
"description": et.Description,
"rdnAttribute": et.RDNAttribute,
"domain": et.Domain,
"requiredAttrs": et.RequiredAttrs,
})
}
return c.JSON(types.NewDataResponse(result, reqID))
}
// List handles GET /api/v1/ldap/entities/:type — list entities of a type
func (h *LDAPEntityHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
typeName := c.Params("type")
search := c.Query("search")
limit := c.QueryInt("limit", 50)
if limit > 500 {
limit = 500
}
if h.registry.GetEntityType(typeName) == nil {
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
entities, err := h.svc.ListEntities(typeName, search, limit)
if err != nil {
apiErr := classifyAPIError(err)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(entities, int64(len(entities)), limit, 0, reqID))
}
// Get handles GET /api/v1/ldap/entities/:type/:rdn — get a single entity
func (h *LDAPEntityHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
typeName := c.Params("type")
rdn := c.Params("rdn")
if h.registry.GetEntityType(typeName) == nil {
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
entity, err := h.svc.GetEntity(typeName, rdn)
if err != nil {
apiErr := classifyAPIError(err)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if entity == nil {
apiErr := types.NewNotFound("Entity not found: " + rdn)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(entity, reqID))
}
// Create handles POST /api/v1/ldap/entities/:type — create an entity
func (h *LDAPEntityHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
typeName := c.Params("type")
if h.registry.GetEntityType(typeName) == nil {
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.LDAPEntityCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if len(req.Attributes) == 0 {
apiErr := types.NewValidation("attributes are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
entity, err := h.svc.CreateEntity(typeName, &req)
if err != nil {
apiErr := classifyAPIError(err)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(entity, reqID))
}
// Update handles PUT /api/v1/ldap/entities/:type/:rdn — update an entity
func (h *LDAPEntityHandler) Update(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
typeName := c.Params("type")
rdn := c.Params("rdn")
if h.registry.GetEntityType(typeName) == nil {
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.LDAPEntityUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
entity, err := h.svc.UpdateEntity(typeName, rdn, &req)
if err != nil {
apiErr := classifyAPIError(err)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(entity, reqID))
}
// Delete handles DELETE /api/v1/ldap/entities/:type/:rdn — delete an entity
func (h *LDAPEntityHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
typeName := c.Params("type")
rdn := c.Params("rdn")
if h.registry.GetEntityType(typeName) == nil {
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteEntity(typeName, rdn); err != nil {
apiErr := classifyAPIError(err)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"type": typeName, "rdn": rdn, "deleted": true}, reqID))
}
// classifyAPIError maps service errors to appropriate HTTP error responses
func classifyAPIError(err error) *types.APIError {
kind, msg := service.ClassifyError(err)
switch kind {
case "conflict":
return types.NewConflict(msg)
case "not_found":
return types.NewNotFound(msg)
case "validation":
return types.NewValidation(msg)
default:
return types.NewInternal(msg)
}
}

View File

@@ -0,0 +1,168 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// LDAPGroupHandler handles LDAP group endpoints
type LDAPGroupHandler struct {
svc *service.LDAPService
}
// NewLDAPGroupHandler creates a new LDAP group handler
func NewLDAPGroupHandler(svc *service.LDAPService) *LDAPGroupHandler {
return &LDAPGroupHandler{svc: svc}
}
// List handles GET /api/v1/ldap/groups
func (h *LDAPGroupHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
search := c.Query("search")
limit := c.QueryInt("limit", 50)
if limit > 500 {
limit = 500
}
groups, err := h.svc.ListGroups(search, limit)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(groups, int64(len(groups)), limit, 0, reqID))
}
// Get handles GET /api/v1/ldap/groups/:cn
func (h *LDAPGroupHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
cn := c.Params("cn")
group, err := h.svc.GetGroup(cn)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if group == nil {
apiErr := types.NewNotFound("Group not found: " + cn)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(group, reqID))
}
// Create handles POST /api/v1/ldap/groups
func (h *LDAPGroupHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.LDAPGroupCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.CN == "" {
apiErr := types.NewValidation("cn is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
group, err := h.svc.CreateGroup(&req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(group, reqID))
}
// Update handles PUT /api/v1/ldap/groups/:cn
func (h *LDAPGroupHandler) Update(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
cn := c.Params("cn")
var req types.LDAPGroupUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
group, err := h.svc.UpdateGroup(cn, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(group, reqID))
}
// Delete handles DELETE /api/v1/ldap/groups/:cn
func (h *LDAPGroupHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
cn := c.Params("cn")
if err := h.svc.DeleteGroup(cn); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"cn": cn, "deleted": true}, reqID))
}
// ListMembers handles GET /api/v1/ldap/groups/:cn/members
func (h *LDAPGroupHandler) ListMembers(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
cn := c.Params("cn")
members, err := h.svc.GetGroupMembers(cn)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if members == nil {
apiErr := types.NewNotFound("Group not found: " + cn)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(members, reqID))
}
// AddMembers handles POST /api/v1/ldap/groups/:cn/members
func (h *LDAPGroupHandler) AddMembers(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
cn := c.Params("cn")
var req types.LDAPGroupMemberAdd
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if len(req.Members) == 0 {
apiErr := types.NewValidation("members array is required and must not be empty")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.AddGroupMembers(cn, req.Members); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"cn": cn, "added": req.Members}, reqID))
}
// RemoveMember handles DELETE /api/v1/ldap/groups/:cn/members/:uid
func (h *LDAPGroupHandler) RemoveMember(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
cn := c.Params("cn")
uid := c.Params("uid")
if err := h.svc.RemoveGroupMember(cn, uid); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"cn": cn, "removed": uid}, reqID))
}

View File

@@ -0,0 +1,215 @@
package handler
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// LDAPUserHandler handles LDAP user endpoints
type LDAPUserHandler struct {
svc *service.LDAPService
}
// NewLDAPUserHandler creates a new LDAP user handler
func NewLDAPUserHandler(svc *service.LDAPService) *LDAPUserHandler {
return &LDAPUserHandler{svc: svc}
}
// List handles GET /api/v1/ldap/users
//
// Query parameters:
// - search: free-text search across uid, givenName, sn, mail
// - services: comma-separated service domains (mail,calendar)
// - attr.<ldapAttr>: filter by any LDAP attribute (e.g. attr.gscTenantId=abc123)
// - limit: max results (default 50, max 500)
func (h *LDAPUserHandler) List(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
search := c.Query("search")
limit := c.QueryInt("limit", 50)
if limit > 500 {
limit = 500
}
// Parse services filter: ?services=mail,calendar
var serviceFilters []string
if svcParam := c.Query("services"); svcParam != "" {
serviceFilters = strings.Split(svcParam, ",")
}
// Parse dynamic attribute filters: ?attr.gscTenantId=abc&attr.mail=*@example.com
attrFilters := make(map[string]string)
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
k := string(key)
if strings.HasPrefix(k, "attr.") && len(k) > 5 {
attrName := k[5:]
attrFilters[attrName] = string(value)
}
})
users, err := h.svc.ListUsers(search, limit, serviceFilters, attrFilters)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(users, int64(len(users)), limit, 0, reqID))
}
// Get handles GET /api/v1/ldap/users/:uid
func (h *LDAPUserHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
user, err := h.svc.GetUser(uid)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if user == nil {
apiErr := types.NewNotFound("User not found: " + uid)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(user, reqID))
}
// Create handles POST /api/v1/ldap/users
func (h *LDAPUserHandler) Create(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.LDAPUserCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.UID == "" || req.FirstName == "" || req.LastName == "" {
apiErr := types.NewValidation("uid, firstName, and lastName are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
user, err := h.svc.CreateUser(&req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(user, reqID))
}
// Update handles PUT /api/v1/ldap/users/:uid
func (h *LDAPUserHandler) Update(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
var req types.LDAPUserUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
user, err := h.svc.UpdateUser(uid, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(user, reqID))
}
// Delete handles DELETE /api/v1/ldap/users/:uid (disables the user)
func (h *LDAPUserHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
if err := h.svc.DisableUser(uid); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"uid": uid, "disabled": true}, reqID))
}
// ResetPassword handles POST /api/v1/ldap/users/:uid/password
func (h *LDAPUserHandler) ResetPassword(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
var req types.PasswordReset
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.NewPassword == "" {
apiErr := types.NewValidation("newPassword is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.ResetPassword(uid, req.NewPassword); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"uid": uid, "passwordReset": true}, reqID))
}
// ListGroups handles GET /api/v1/ldap/users/:uid/groups
func (h *LDAPUserHandler) ListGroups(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
groups, err := h.svc.GetUserGroups(uid)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if groups == nil {
apiErr := types.NewNotFound("User not found: " + uid)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(groups, reqID))
}
// ListServices handles GET /api/v1/ldap/users/:uid/services
func (h *LDAPUserHandler) ListServices(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
services, err := h.svc.GetUserServices(uid, "")
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if services == nil {
apiErr := types.NewNotFound("User not found: " + uid)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(services, reqID))
}
// GetService handles GET /api/v1/ldap/users/:uid/services/:domain
func (h *LDAPUserHandler) GetService(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
uid := c.Params("uid")
domain := c.Params("domain")
services, err := h.svc.GetUserServices(uid, domain)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if services == nil {
apiErr := types.NewNotFound("User not found: " + uid)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(services, reqID))
}

603
internal/handler/pbx.go Normal file
View File

@@ -0,0 +1,603 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PBXHandler handles PBX management endpoints
type PBXHandler struct {
svc *service.PBXService
}
// NewPBXHandler creates a new PBX handler
func NewPBXHandler(svc *service.PBXService) *PBXHandler {
return &PBXHandler{svc: svc}
}
// ============================================================================
// Trunks
// ============================================================================
// ListTrunks handles GET /api/v1/pbx/trunks
func (h *PBXHandler) ListTrunks(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
Status: c.Query("status"),
}
trunks, total, err := h.svc.ListTrunks(c.Context(), params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(trunks, total, params.Limit, params.Offset, reqID))
}
// GetTrunk handles GET /api/v1/pbx/trunks/:id
func (h *PBXHandler) GetTrunk(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
trunk, err := h.svc.GetTrunk(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("Trunk not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(trunk, reqID))
}
// CreateTrunk handles POST /api/v1/pbx/trunks
func (h *PBXHandler) CreateTrunk(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.PBXTrunkCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Name == "" || req.Host == "" || req.TenantID == uuid.Nil {
apiErr := types.NewValidation("tenantId, name, and host are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
trunk, err := h.svc.CreateTrunk(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(trunk, reqID))
}
// UpdateTrunk handles PUT /api/v1/pbx/trunks/:id
func (h *PBXHandler) UpdateTrunk(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.PBXTrunkUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
trunk, err := h.svc.UpdateTrunk(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(trunk, reqID))
}
// DeleteTrunk handles DELETE /api/v1/pbx/trunks/:id
func (h *PBXHandler) DeleteTrunk(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteTrunk(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
}
// ActivateTrunk handles POST /api/v1/pbx/trunks/:id/activate
func (h *PBXHandler) ActivateTrunk(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
trunk, err := h.svc.ActivateTrunk(c.Context(), id)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(trunk, reqID))
}
// DeactivateTrunk handles POST /api/v1/pbx/trunks/:id/deactivate
func (h *PBXHandler) DeactivateTrunk(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
trunk, err := h.svc.DeactivateTrunk(c.Context(), id)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(trunk, reqID))
}
// ============================================================================
// Trunk DIDs
// ============================================================================
// ListTrunkDIDs handles GET /api/v1/pbx/trunks/:id/dids
func (h *PBXHandler) ListTrunkDIDs(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
trunkID, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
dids, err := h.svc.ListTrunkDIDs(c.Context(), trunkID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(dids, reqID))
}
// CreateTrunkDID handles POST /api/v1/pbx/trunks/:id/dids
func (h *PBXHandler) CreateTrunkDID(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
trunkID, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.PBXTrunkDIDCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.DIDNumber == "" || req.TenantID == uuid.Nil {
apiErr := types.NewValidation("tenantId and didNumber are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
did, err := h.svc.CreateTrunkDID(c.Context(), trunkID, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(did, reqID))
}
// DeleteTrunkDID handles DELETE /api/v1/pbx/trunks/:id/dids/:didId
func (h *PBXHandler) DeleteTrunkDID(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
trunkID, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid trunk ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
didID, err := uuid.Parse(c.Params("didId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid DID ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteTrunkDID(c.Context(), trunkID, didID); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": didID, "deleted": true}, reqID))
}
// ============================================================================
// Extensions
// ============================================================================
// ListExtensions handles GET /api/v1/pbx/extensions
func (h *PBXHandler) ListExtensions(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
Status: c.Query("status"),
}
exts, total, err := h.svc.ListExtensions(c.Context(), params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(exts, total, params.Limit, params.Offset, reqID))
}
// GetExtension handles GET /api/v1/pbx/extensions/:id
func (h *PBXHandler) GetExtension(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid extension ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
ext, err := h.svc.GetExtension(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("Extension not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(ext, reqID))
}
// CreateExtension handles POST /api/v1/pbx/extensions
func (h *PBXHandler) CreateExtension(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.PBXExtensionCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Extension == "" || req.Name == "" || req.TenantID == uuid.Nil {
apiErr := types.NewValidation("tenantId, extension, and name are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
ext, err := h.svc.CreateExtension(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(ext, reqID))
}
// UpdateExtension handles PUT /api/v1/pbx/extensions/:id
func (h *PBXHandler) UpdateExtension(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid extension ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.PBXExtensionUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
ext, err := h.svc.UpdateExtension(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(ext, reqID))
}
// DeleteExtension handles DELETE /api/v1/pbx/extensions/:id
func (h *PBXHandler) DeleteExtension(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid extension ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteExtension(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
}
// ============================================================================
// Inbound Routes
// ============================================================================
// ListInboundRoutes handles GET /api/v1/pbx/inbound-routes
func (h *PBXHandler) ListInboundRoutes(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
}
routes, total, err := h.svc.ListInboundRoutes(c.Context(), params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(routes, total, params.Limit, params.Offset, reqID))
}
// GetInboundRoute handles GET /api/v1/pbx/inbound-routes/:id
func (h *PBXHandler) GetInboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid route ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
route, err := h.svc.GetInboundRoute(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("Inbound route not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(route, reqID))
}
// CreateInboundRoute handles POST /api/v1/pbx/inbound-routes
func (h *PBXHandler) CreateInboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.PBXInboundRouteCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Name == "" || req.DestinationType == "" || req.TenantID == uuid.Nil {
apiErr := types.NewValidation("tenantId, name, and destinationType are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
route, err := h.svc.CreateInboundRoute(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(route, reqID))
}
// UpdateInboundRoute handles PUT /api/v1/pbx/inbound-routes/:id
func (h *PBXHandler) UpdateInboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid route ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.PBXInboundRouteUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
route, err := h.svc.UpdateInboundRoute(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(route, reqID))
}
// DeleteInboundRoute handles DELETE /api/v1/pbx/inbound-routes/:id
func (h *PBXHandler) DeleteInboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid route ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteInboundRoute(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
}
// ============================================================================
// Outbound Routes
// ============================================================================
// ListOutboundRoutes handles GET /api/v1/pbx/outbound-routes
func (h *PBXHandler) ListOutboundRoutes(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
}
routes, total, err := h.svc.ListOutboundRoutes(c.Context(), params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(routes, total, params.Limit, params.Offset, reqID))
}
// GetOutboundRoute handles GET /api/v1/pbx/outbound-routes/:id
func (h *PBXHandler) GetOutboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid route ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
route, err := h.svc.GetOutboundRoute(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("Outbound route not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(route, reqID))
}
// CreateOutboundRoute handles POST /api/v1/pbx/outbound-routes
func (h *PBXHandler) CreateOutboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.PBXOutboundRouteCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Name == "" || len(req.DialPatterns) == 0 || req.TenantID == uuid.Nil {
apiErr := types.NewValidation("tenantId, name, and dialPatterns are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
route, err := h.svc.CreateOutboundRoute(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(route, reqID))
}
// UpdateOutboundRoute handles PUT /api/v1/pbx/outbound-routes/:id
func (h *PBXHandler) UpdateOutboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid route ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.PBXOutboundRouteUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
route, err := h.svc.UpdateOutboundRoute(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(route, reqID))
}
// DeleteOutboundRoute handles DELETE /api/v1/pbx/outbound-routes/:id
func (h *PBXHandler) DeleteOutboundRoute(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid route ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteOutboundRoute(c.Context(), id); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
}
// ============================================================================
// System Operations
// ============================================================================
// Status handles GET /api/v1/pbx/status
func (h *PBXHandler) Status(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
status, err := h.svc.GetStatus(c.Context())
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(status, reqID))
}
// Reload handles POST /api/v1/pbx/reload
func (h *PBXHandler) Reload(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
result, err := h.svc.Reload(c.Context())
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(result, reqID))
}

259
internal/handler/persona.go Normal file
View File

@@ -0,0 +1,259 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PersonaHandler handles persona management endpoints
type PersonaHandler struct {
svc *service.PersonaService
}
// NewPersonaHandler creates a new persona handler
func NewPersonaHandler(svc *service.PersonaService) *PersonaHandler {
return &PersonaHandler{svc: svc}
}
// parseTenantID extracts and validates tenantId from query or body
func parseTenantID(c *fiber.Ctx) (uuid.UUID, *types.APIError) {
tid := c.Query("tenantId")
if tid == "" {
return uuid.Nil, types.NewBadRequest("tenantId query parameter is required")
}
parsed, err := uuid.Parse(tid)
if err != nil {
return uuid.Nil, types.NewBadRequest("Invalid tenantId")
}
return parsed, nil
}
// ListPersonas handles GET /api/v1/personas
func (h *PersonaHandler) ListPersonas(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Status: c.Query("status"),
}
personas, total, err := h.svc.ListPersonas(c.Context(), tenantID, params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(personas, total, params.Limit, params.Offset, reqID))
}
// GetPersona handles GET /api/v1/personas/:id
func (h *PersonaHandler) GetPersona(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid persona ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
persona, err := h.svc.GetPersona(c.Context(), id, tenantID)
if err != nil {
apiErr := types.NewNotFound("Persona not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(persona, reqID))
}
// CreatePersona handles POST /api/v1/personas
func (h *PersonaHandler) CreatePersona(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.PersonaCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.TenantID == uuid.Nil {
apiErr := types.NewValidation("tenantId is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.Name == "" {
apiErr := types.NewValidation("name is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
persona, err := h.svc.CreatePersona(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(persona, reqID))
}
// UpdatePersona handles PUT /api/v1/personas/:id
func (h *PersonaHandler) UpdatePersona(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid persona ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.PersonaUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
persona, err := h.svc.UpdatePersona(c.Context(), id, tenantID, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(persona, reqID))
}
// DeletePersona handles DELETE /api/v1/personas/:id
func (h *PersonaHandler) DeletePersona(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid persona ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeletePersona(c.Context(), id, tenantID); err != nil {
apiErr := types.NewNotFound("Persona not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.SendStatus(fiber.StatusNoContent)
}
// GetSelfModel handles GET /api/v1/personas/:id/self-model
func (h *PersonaHandler) GetSelfModel(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid persona ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
snapshot, err := h.svc.GetSelfModel(c.Context(), id, tenantID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(snapshot, reqID))
}
// GetExperiences handles GET /api/v1/personas/:id/experiences
func (h *PersonaHandler) GetExperiences(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid persona ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
limit := c.QueryInt("limit", 20)
experiences, err := h.svc.SearchExperiences(c.Context(), id, tenantID, limit)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(experiences, reqID))
}
// GetEvaluations handles GET /api/v1/personas/:id/evaluations/:sessionId
func (h *PersonaHandler) GetEvaluations(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
sessionID, err := uuid.Parse(c.Params("sessionId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid session ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
limit := c.QueryInt("limit", 10)
evaluations, err := h.svc.GetEvaluations(c.Context(), sessionID, limit)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(evaluations, reqID))
}
// GetMoralPattern handles GET /api/v1/personas/:id/moral-pattern/:sessionId
func (h *PersonaHandler) GetMoralPattern(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
sessionID, err := uuid.Parse(c.Params("sessionId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid session ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, apiErr := parseTenantID(c)
if apiErr != nil {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
assessments, err := h.svc.GetMoralPattern(c.Context(), sessionID, tenantID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(map[string]interface{}{
"assessments": assessments,
}, reqID))
}

View File

@@ -0,0 +1,98 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PersonalAgentHandler handles personal agent config endpoints
type PersonalAgentHandler struct {
svc *service.PersonalAgentService
}
// NewPersonalAgentHandler creates a new personal agent handler
func NewPersonalAgentHandler(svc *service.PersonalAgentService) *PersonalAgentHandler {
return &PersonalAgentHandler{svc: svc}
}
// GetMyConfig handles GET /api/v1/agents/me?userId=X&tenantId=Y
func (h *PersonalAgentHandler) GetMyConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
userID, err := uuid.Parse(c.Query("userId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid or missing userId")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, err := uuid.Parse(c.Query("tenantId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid or missing tenantId")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
config, err := h.svc.GetConfig(c.Context(), userID, tenantID)
if err != nil {
apiErr := types.NewNotFound("Personal agent config not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(config, reqID))
}
// UpsertMyConfig handles PUT /api/v1/agents/me
func (h *PersonalAgentHandler) UpsertMyConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.UserAgentConfigUpsert
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.UserID == uuid.Nil || req.TenantID == uuid.Nil {
apiErr := types.NewValidation("userId and tenantId are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if len(req.Config) == 0 {
apiErr := types.NewValidation("config is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
config, err := h.svc.UpsertConfig(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(config, reqID))
}
// DeleteMyConfig handles DELETE /api/v1/agents/me?userId=X&tenantId=Y
func (h *PersonalAgentHandler) DeleteMyConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
userID, err := uuid.Parse(c.Query("userId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid or missing userId")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID, err := uuid.Parse(c.Query("tenantId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid or missing tenantId")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteConfig(c.Context(), userID, tenantID); err != nil {
apiErr := types.NewNotFound("Personal agent config not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.SendStatus(fiber.StatusNoContent)
}

100
internal/handler/pgp.go Normal file
View File

@@ -0,0 +1,100 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PGPHandler handles PGP key endpoints
type PGPHandler struct {
svc *service.PGPService
}
// NewPGPHandler creates a new PGP handler
func NewPGPHandler(svc *service.PGPService) *PGPHandler {
return &PGPHandler{svc: svc}
}
// Search handles GET /api/v1/pgp/keys
func (h *PGPHandler) Search(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
query := c.Query("search")
if query == "" {
query = c.Query("q")
}
if query == "" {
apiErr := types.NewValidation("search or q query parameter is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
keys, err := h.svc.SearchKeys(query)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(keys, reqID))
}
// Get handles GET /api/v1/pgp/keys/:keyId
func (h *PGPHandler) Get(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
keyID := c.Params("keyId")
key, err := h.svc.GetKey(keyID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if key == nil {
apiErr := types.NewNotFound("PGP key not found: " + keyID)
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(key, reqID))
}
// Upload handles POST /api/v1/pgp/keys
func (h *PGPHandler) Upload(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.PGPKeyUpload
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.KeyText == "" {
apiErr := types.NewValidation("keyText is required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.UploadKey(req.KeyText); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(fiber.Map{
"uploaded": true,
}, reqID))
}
// Delete handles DELETE /api/v1/pgp/keys/:keyId
func (h *PGPHandler) Delete(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
keyID := c.Params("keyId")
if err := h.svc.DeleteKey(keyID); err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(fiber.Map{
"keyId": keyID,
"deleted": true,
}, reqID))
}

View File

@@ -0,0 +1,186 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/gosec/gsc-ops-api/internal/middleware"
"github.com/gosec/gsc-ops-api/internal/service"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// VoiceAgentHandler handles voice agent management endpoints
type VoiceAgentHandler struct {
svc *service.VoiceAgentService
}
// NewVoiceAgentHandler creates a new voice agent handler
func NewVoiceAgentHandler(svc *service.VoiceAgentService) *VoiceAgentHandler {
return &VoiceAgentHandler{svc: svc}
}
// ============================================================================
// Voice Agent Configs
// ============================================================================
// ListConfigs handles GET /api/v1/voice-agents
func (h *VoiceAgentHandler) ListConfigs(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
Search: c.Query("search"),
}
var tenantID *uuid.UUID
if tid := c.Query("tenantId"); tid != "" {
parsed, err := uuid.Parse(tid)
if err != nil {
apiErr := types.NewBadRequest("Invalid tenantId")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
tenantID = &parsed
}
configs, total, err := h.svc.ListConfigs(c.Context(), params, tenantID)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(configs, total, params.Limit, params.Offset, reqID))
}
// GetConfig handles GET /api/v1/voice-agents/:id
func (h *VoiceAgentHandler) GetConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid voice agent config ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
config, err := h.svc.GetConfig(c.Context(), id)
if err != nil {
apiErr := types.NewNotFound("Voice agent config not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(config, reqID))
}
// CreateConfig handles POST /api/v1/voice-agents
func (h *VoiceAgentHandler) CreateConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
var req types.VoiceAgentConfigCreate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if req.TenantID == uuid.Nil || req.AgentID == uuid.Nil {
apiErr := types.NewValidation("tenantId and agentId are required")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
config, err := h.svc.CreateConfig(c.Context(), &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(config, reqID))
}
// UpdateConfig handles PUT /api/v1/voice-agents/:id
func (h *VoiceAgentHandler) UpdateConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid voice agent config ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
var req types.VoiceAgentConfigUpdate
if err := c.BodyParser(&req); err != nil {
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
config, err := h.svc.UpdateConfig(c.Context(), id, &req)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(config, reqID))
}
// DeleteConfig handles DELETE /api/v1/voice-agents/:id
func (h *VoiceAgentHandler) DeleteConfig(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
id, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid voice agent config ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
if err := h.svc.DeleteConfig(c.Context(), id); err != nil {
apiErr := types.NewNotFound("Voice agent config not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.SendStatus(fiber.StatusNoContent)
}
// ============================================================================
// Voice Sessions
// ============================================================================
// ListSessions handles GET /api/v1/voice-agents/:id/sessions
func (h *VoiceAgentHandler) ListSessions(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
agentID, err := uuid.Parse(c.Params("id"))
if err != nil {
apiErr := types.NewBadRequest("Invalid voice agent config ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
params := types.ListParams{
Limit: c.QueryInt("limit", 50),
Offset: c.QueryInt("offset", 0),
}
sessions, total, err := h.svc.ListSessions(c.Context(), agentID, params)
if err != nil {
apiErr := types.NewInternal(err.Error())
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewPagedResponse(sessions, total, params.Limit, params.Offset, reqID))
}
// GetSession handles GET /api/v1/voice-agents/sessions/:sessionId
func (h *VoiceAgentHandler) GetSession(c *fiber.Ctx) error {
reqID := middleware.GetRequestID(c)
sessionID, err := uuid.Parse(c.Params("sessionId"))
if err != nil {
apiErr := types.NewBadRequest("Invalid session ID")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
session, err := h.svc.GetSession(c.Context(), sessionID)
if err != nil {
apiErr := types.NewNotFound("Voice session not found")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
}
return c.JSON(types.NewDataResponse(session, reqID))
}

View File

@@ -0,0 +1,37 @@
package middleware
import (
"crypto/subtle"
"github.com/gofiber/fiber/v2"
"github.com/gosec/gsc-ops-api/pkg/types"
)
const APIKeyHeader = "X-API-Key"
// APIKey validates the X-API-Key header against configured keys
func APIKey(validKeys []string) fiber.Handler {
return func(c *fiber.Ctx) error {
key := c.Get(APIKeyHeader)
if key == "" {
apiErr := types.NewUnauthorized("Missing API key")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c)))
}
valid := false
for _, vk := range validKeys {
if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 {
valid = true
break
}
}
if !valid {
apiErr := types.NewUnauthorized("Invalid API key")
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c)))
}
return c.Next()
}
}

View File

@@ -0,0 +1,68 @@
package middleware
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
// JWTClaims contains extracted claims from the JWT
type JWTClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
}
// JWTExtract extracts JWT claims from the Authorization header for audit context.
// This middleware does NOT validate the JWT — it only extracts claims.
// Authentication is handled by mTLS + API key. JWT is optional passthrough.
func JWTExtract() fiber.Handler {
return func(c *fiber.Ctx) error {
auth := c.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
return c.Next()
}
tokenStr := strings.TrimPrefix(auth, "Bearer ")
// Parse without validation — we trust the API key for auth
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
token, _, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{})
if err != nil {
// Invalid JWT — ignore, not blocking
return c.Next()
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return c.Next()
}
jwtClaims := &JWTClaims{}
if sub, ok := claims["sub"].(string); ok {
jwtClaims.Subject = sub
}
if email, ok := claims["email"].(string); ok {
jwtClaims.Email = email
}
if name, ok := claims["name"].(string); ok {
jwtClaims.Name = name
}
if tid, ok := claims["tenantId"].(string); ok {
jwtClaims.TenantID = tid
}
c.Locals("jwtClaims", jwtClaims)
return c.Next()
}
}
// GetJWTClaims retrieves JWT claims from context
func GetJWTClaims(c *fiber.Ctx) *JWTClaims {
if claims, ok := c.Locals("jwtClaims").(*JWTClaims); ok {
return claims
}
return nil
}

View File

@@ -0,0 +1,39 @@
package middleware
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/rs/zerolog"
)
// Logging provides structured request logging via zerolog
func Logging(logger zerolog.Logger) fiber.Handler {
return func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
duration := time.Since(start)
status := c.Response().StatusCode()
event := logger.Info()
if status >= 500 {
event = logger.Error()
} else if status >= 400 {
event = logger.Warn()
}
event.
Str("method", c.Method()).
Str("path", c.Path()).
Int("status", status).
Dur("duration", duration).
Str("requestId", GetRequestID(c)).
Str("ip", c.IP()).
Str("userAgent", c.Get("User-Agent")).
Msg("request")
return err
}
}

View File

@@ -0,0 +1,29 @@
package middleware
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
const RequestIDHeader = "X-Request-ID"
// RequestID generates or extracts a request ID for each request
func RequestID() fiber.Handler {
return func(c *fiber.Ctx) error {
reqID := c.Get(RequestIDHeader)
if reqID == "" {
reqID = uuid.New().String()
}
c.Locals("requestId", reqID)
c.Set(RequestIDHeader, reqID)
return c.Next()
}
}
// GetRequestID extracts the request ID from context
func GetRequestID(c *fiber.Ctx) string {
if id, ok := c.Locals("requestId").(string); ok {
return id
}
return ""
}

225
internal/router/router.go Normal file
View File

@@ -0,0 +1,225 @@
package router
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/handler"
"github.com/gosec/gsc-ops-api/internal/middleware"
)
// Config holds all handler dependencies for route registration
type Config struct {
Logger zerolog.Logger
APIKeys []string
Health *handler.HealthHandler
LDAPUsers *handler.LDAPUserHandler
LDAPGroups *handler.LDAPGroupHandler
LDAPEntities *handler.LDAPEntityHandler
DNSZones *handler.DNSZoneHandler
DNSRecords *handler.DNSRecordHandler
DBTenants *handler.DBTenantHandler
DBUsers *handler.DBUserHandler
Certs *handler.CertHandler
PGP *handler.PGPHandler
CardDAV *handler.CardDAVHandler
PBX *handler.PBXHandler
VoiceAgent *handler.VoiceAgentHandler
PersonalAgent *handler.PersonalAgentHandler
Persona *handler.PersonaHandler
}
// Setup registers all routes on the Fiber app
func Setup(app *fiber.App, cfg *Config) {
// Global middleware
app.Use(recover.New())
app.Use(middleware.RequestID())
app.Use(middleware.Logging(cfg.Logger))
app.Use(middleware.JWTExtract())
// Health endpoints (no API key required)
app.Get("/health", cfg.Health.Liveness)
app.Get("/ready", cfg.Health.Readiness)
// API v1 routes (API key required)
api := app.Group("/api/v1", middleware.APIKey(cfg.APIKeys))
// LDAP Users
ldapUsers := api.Group("/ldap/users")
ldapUsers.Get("/", cfg.LDAPUsers.List)
ldapUsers.Get("/:uid", cfg.LDAPUsers.Get)
ldapUsers.Post("/", cfg.LDAPUsers.Create)
ldapUsers.Put("/:uid", cfg.LDAPUsers.Update)
ldapUsers.Delete("/:uid", cfg.LDAPUsers.Delete)
ldapUsers.Post("/:uid/password", cfg.LDAPUsers.ResetPassword)
ldapUsers.Get("/:uid/groups", cfg.LDAPUsers.ListGroups)
ldapUsers.Get("/:uid/services", cfg.LDAPUsers.ListServices)
ldapUsers.Get("/:uid/services/:domain", cfg.LDAPUsers.GetService)
// LDAP Groups
ldapGroups := api.Group("/ldap/groups")
ldapGroups.Get("/", cfg.LDAPGroups.List)
ldapGroups.Get("/:cn", cfg.LDAPGroups.Get)
ldapGroups.Post("/", cfg.LDAPGroups.Create)
ldapGroups.Put("/:cn", cfg.LDAPGroups.Update)
ldapGroups.Delete("/:cn", cfg.LDAPGroups.Delete)
ldapGroups.Get("/:cn/members", cfg.LDAPGroups.ListMembers)
ldapGroups.Post("/:cn/members", cfg.LDAPGroups.AddMembers)
ldapGroups.Delete("/:cn/members/:uid", cfg.LDAPGroups.RemoveMember)
// LDAP Entities (generic CRUD)
ldapEntities := api.Group("/ldap/entities")
ldapEntities.Get("/", cfg.LDAPEntities.ListTypes)
ldapEntities.Get("/:type", cfg.LDAPEntities.List)
ldapEntities.Post("/:type", cfg.LDAPEntities.Create)
ldapEntities.Get("/:type/:rdn", cfg.LDAPEntities.Get)
ldapEntities.Put("/:type/:rdn", cfg.LDAPEntities.Update)
ldapEntities.Delete("/:type/:rdn", cfg.LDAPEntities.Delete)
// DNS Zones
dnsZones := api.Group("/dns/zones")
dnsZones.Get("/", cfg.DNSZones.List)
dnsZones.Get("/:zoneId", cfg.DNSZones.Get)
dnsZones.Post("/", cfg.DNSZones.Create)
dnsZones.Put("/:zoneId", cfg.DNSZones.Update)
dnsZones.Delete("/:zoneId", cfg.DNSZones.Delete)
dnsZones.Post("/:zoneId/notify", cfg.DNSZones.Notify)
// DNS Records
dnsZones.Get("/:zoneId/records", cfg.DNSRecords.List)
dnsZones.Post("/:zoneId/records", cfg.DNSRecords.Create)
dnsZones.Put("/:zoneId/records", cfg.DNSRecords.Replace)
dnsZones.Delete("/:zoneId/records", cfg.DNSRecords.Delete)
// DNS Domains (orchestrated)
dnsDomains := api.Group("/dns/domains")
dnsDomains.Post("/setup", cfg.DNSRecords.DomainSetup)
dnsDomains.Post("/verify", cfg.DNSRecords.DomainVerify)
// DB Tenants
dbTenants := api.Group("/db/tenants")
dbTenants.Get("/", cfg.DBTenants.List)
dbTenants.Get("/:id", cfg.DBTenants.Get)
dbTenants.Post("/", cfg.DBTenants.Create)
dbTenants.Put("/:id", cfg.DBTenants.Update)
dbTenants.Delete("/:id", cfg.DBTenants.Delete)
// DB Users
dbUsers := api.Group("/db/users")
dbUsers.Get("/", cfg.DBUsers.List)
dbUsers.Get("/:id", cfg.DBUsers.Get)
dbUsers.Post("/", cfg.DBUsers.Create)
dbUsers.Put("/:id", cfg.DBUsers.Update)
dbUsers.Delete("/:id", cfg.DBUsers.Delete)
// Certificates
certs := api.Group("/certs")
certs.Get("/", cfg.Certs.List)
certs.Get("/:serialNumber", cfg.Certs.Get)
certs.Post("/request", cfg.Certs.Request)
certs.Post("/:serialNumber/renew", cfg.Certs.Renew)
certs.Post("/:serialNumber/revoke", cfg.Certs.Revoke)
// PGP Keys
pgp := api.Group("/pgp/keys")
pgp.Get("/", cfg.PGP.Search)
pgp.Get("/:keyId", cfg.PGP.Get)
pgp.Post("/", cfg.PGP.Upload)
pgp.Delete("/:keyId", cfg.PGP.Delete)
// PBX
if cfg.PBX != nil {
pbxTrunks := api.Group("/pbx/trunks")
pbxTrunks.Get("/", cfg.PBX.ListTrunks)
pbxTrunks.Post("/", cfg.PBX.CreateTrunk)
pbxTrunks.Get("/:id", cfg.PBX.GetTrunk)
pbxTrunks.Put("/:id", cfg.PBX.UpdateTrunk)
pbxTrunks.Delete("/:id", cfg.PBX.DeleteTrunk)
pbxTrunks.Post("/:id/activate", cfg.PBX.ActivateTrunk)
pbxTrunks.Post("/:id/deactivate", cfg.PBX.DeactivateTrunk)
pbxTrunks.Get("/:id/dids", cfg.PBX.ListTrunkDIDs)
pbxTrunks.Post("/:id/dids", cfg.PBX.CreateTrunkDID)
pbxTrunks.Delete("/:id/dids/:didId", cfg.PBX.DeleteTrunkDID)
pbxExts := api.Group("/pbx/extensions")
pbxExts.Get("/", cfg.PBX.ListExtensions)
pbxExts.Post("/", cfg.PBX.CreateExtension)
pbxExts.Get("/:id", cfg.PBX.GetExtension)
pbxExts.Put("/:id", cfg.PBX.UpdateExtension)
pbxExts.Delete("/:id", cfg.PBX.DeleteExtension)
pbxInbound := api.Group("/pbx/inbound-routes")
pbxInbound.Get("/", cfg.PBX.ListInboundRoutes)
pbxInbound.Post("/", cfg.PBX.CreateInboundRoute)
pbxInbound.Get("/:id", cfg.PBX.GetInboundRoute)
pbxInbound.Put("/:id", cfg.PBX.UpdateInboundRoute)
pbxInbound.Delete("/:id", cfg.PBX.DeleteInboundRoute)
pbxOutbound := api.Group("/pbx/outbound-routes")
pbxOutbound.Get("/", cfg.PBX.ListOutboundRoutes)
pbxOutbound.Post("/", cfg.PBX.CreateOutboundRoute)
pbxOutbound.Get("/:id", cfg.PBX.GetOutboundRoute)
pbxOutbound.Put("/:id", cfg.PBX.UpdateOutboundRoute)
pbxOutbound.Delete("/:id", cfg.PBX.DeleteOutboundRoute)
api.Get("/pbx/status", cfg.PBX.Status)
api.Post("/pbx/reload", cfg.PBX.Reload)
}
// Voice Agents
if cfg.VoiceAgent != nil {
voiceAgents := api.Group("/voice-agents")
voiceAgents.Get("/", cfg.VoiceAgent.ListConfigs)
voiceAgents.Post("/", cfg.VoiceAgent.CreateConfig)
voiceAgents.Get("/sessions/:sessionId", cfg.VoiceAgent.GetSession)
voiceAgents.Get("/:id", cfg.VoiceAgent.GetConfig)
voiceAgents.Put("/:id", cfg.VoiceAgent.UpdateConfig)
voiceAgents.Delete("/:id", cfg.VoiceAgent.DeleteConfig)
voiceAgents.Get("/:id/sessions", cfg.VoiceAgent.ListSessions)
}
// Personal Agents (user agent configs)
if cfg.PersonalAgent != nil {
agents := api.Group("/agents")
agents.Get("/me", cfg.PersonalAgent.GetMyConfig)
agents.Put("/me", cfg.PersonalAgent.UpsertMyConfig)
agents.Delete("/me", cfg.PersonalAgent.DeleteMyConfig)
}
// Personas
if cfg.Persona != nil {
personas := api.Group("/personas")
personas.Get("/", cfg.Persona.ListPersonas)
personas.Post("/", cfg.Persona.CreatePersona)
personas.Get("/:id", cfg.Persona.GetPersona)
personas.Put("/:id", cfg.Persona.UpdatePersona)
personas.Delete("/:id", cfg.Persona.DeletePersona)
personas.Get("/:id/self-model", cfg.Persona.GetSelfModel)
personas.Get("/:id/experiences", cfg.Persona.GetExperiences)
personas.Get("/:id/evaluations/:sessionId", cfg.Persona.GetEvaluations)
personas.Get("/:id/moral-pattern/:sessionId", cfg.Persona.GetMoralPattern)
}
// CardDAV
if cfg.CardDAV != nil {
cardDAVPrincipals := api.Group("/carddav/principals")
cardDAVPrincipals.Get("/", cfg.CardDAV.ListPrincipals)
cardDAVPrincipals.Get("/:username", cfg.CardDAV.GetPrincipal)
cardDAVPrincipals.Post("/", cfg.CardDAV.CreatePrincipal)
cardDAVPrincipals.Delete("/:username", cfg.CardDAV.DeletePrincipal)
cardDAVBooks := api.Group("/carddav/addressbooks")
cardDAVBooks.Get("/", cfg.CardDAV.ListAddressBooks)
cardDAVBooks.Get("/:id", cfg.CardDAV.GetAddressBook)
cardDAVBooks.Post("/", cfg.CardDAV.CreateAddressBook)
cardDAVBooks.Put("/:id", cfg.CardDAV.UpdateAddressBook)
cardDAVBooks.Delete("/:id", cfg.CardDAV.DeleteAddressBook)
cardDAVBooks.Get("/:id/contacts", cfg.CardDAV.ListContacts)
cardDAVBooks.Get("/:id/contacts/:uri", cfg.CardDAV.GetContact)
cardDAVBooks.Post("/:id/contacts", cfg.CardDAV.CreateContact)
cardDAVBooks.Put("/:id/contacts/:uri", cfg.CardDAV.UpdateContact)
cardDAVBooks.Delete("/:id/contacts/:uri", cfg.CardDAV.DeleteContact)
}
}

View File

@@ -0,0 +1,341 @@
package schema
// registerAttributes registers all 292 GoSec LDAP attribute definitions
// organized by OID branch/domain.
func (r *Registry) registerAttributes() {
// ── common (7) ──────────────────────────────────────────────────
r.addAttr("gscCreatedAt", "createdAt", AttrTime, "common", true)
r.addAttr("gscModifiedAt", "modifiedAt", AttrTime, "common", true)
r.addAttr("gscCreatedBy", "createdBy", AttrDN, "common", true)
r.addAttr("gscModifiedBy", "modifiedBy", AttrDN, "common", true)
r.addAttr("gscDescription", "description", AttrString, "common", false)
r.addAttr("gscEnabled", "enabled", AttrBool, "common", false)
r.addAttr("gscNotes", "notes", AttrString, "common", false)
// ── tenant (10) ─────────────────────────────────────────────────
r.addAttr("gscTenantId", "tenantId", AttrString, "tenant", false)
r.addAttr("gscTenantName", "tenantName", AttrString, "tenant", false)
r.addAttr("gscTenantDomain", "tenantDomain", AttrString, "tenant", false)
r.addAttr("gscTenantStatus", "tenantStatus", AttrString, "tenant", false)
r.addAttr("gscTenantQuota", "tenantQuota", AttrInt, "tenant", false)
r.addAttr("gscTenantMaxUsers", "tenantMaxUsers", AttrInt, "tenant", false)
r.addAttr("gscTenantCreatedAt", "tenantCreatedAt", AttrTime, "tenant", true)
r.addAttr("gscTenantServices", "tenantServices", AttrStringMulti, "tenant", false)
r.addAttr("gscTenantAdminDN", "tenantAdminDN", AttrDN, "tenant", false)
r.addAttr("gscTenantParentDN", "tenantParentDN", AttrDN, "tenant", false)
// ── hash (5) ────────────────────────────────────────────────────
r.addAttr("gscUserTenantHash", "tenantHash", AttrString, "hash", true)
r.addAttr("gscUserTenantHashSalt", "tenantHashSalt", AttrString, "hash", true)
r.addAttr("gscUserTenantHashVersion", "tenantHashVersion", AttrInt, "hash", true)
r.addAttr("gscUserTenantHashCreatedAt", "tenantHashCreatedAt", AttrTime, "hash", true)
r.addAttr("gscUserTenantHashVerifiedAt", "tenantHashVerifiedAt", AttrTime, "hash", true)
// ── customer (7) ────────────────────────────────────────────────
r.addAttr("gscCustomerId", "customerId", AttrString, "customer", false)
r.addAttr("gscSID", "sid", AttrString, "customer", false)
r.addAttr("gscSIDCustomerPart", "sidCustomerPart", AttrString, "customer", false)
r.addAttr("gscSIDTenantPart", "sidTenantPart", AttrString, "customer", false)
r.addAttr("gscSIDSpecial1", "sidSpecial1", AttrString, "customer", false)
r.addAttr("gscSIDSpecial2", "sidSpecial2", AttrString, "customer", false)
r.addAttr("gscSIDUserPart", "sidUserPart", AttrString, "customer", false)
// ── mail (8) ────────────────────────────────────────────────────
r.addAttr("gscMailEnabled", "enabled", AttrBool, "mail", false)
r.addAttr("gscMailQuota", "quota", AttrInt, "mail", false)
r.addAttr("gscMailAlias", "alias", AttrStringMulti, "mail", false)
r.addAttr("gscMailForward", "forward", AttrString, "mail", false)
r.addAttr("gscMailAutoReply", "autoReply", AttrBool, "mail", false)
r.addAttr("gscMailAutoReplyMessage", "autoReplyMessage", AttrString, "mail", false)
r.addAttr("gscMailTransport", "transport", AttrString, "mail", false)
r.addAttr("gscMailDomain", "domain", AttrString, "mail", false)
// ── conf (5) ────────────────────────────────────────────────────
r.addAttr("gscConfEnabled", "enabled", AttrBool, "conf", false)
r.addAttr("gscConfRole", "role", AttrString, "conf", false)
r.addAttr("gscConfMaxParticipants", "maxParticipants", AttrInt, "conf", false)
r.addAttr("gscConfRecordingEnabled", "recordingEnabled", AttrBool, "conf", false)
r.addAttr("gscConfDefaultRoom", "defaultRoom", AttrString, "conf", false)
// ── ftp (6) ─────────────────────────────────────────────────────
r.addAttr("gscFtpEnabled", "enabled", AttrBool, "ftp", false)
r.addAttr("gscFtpQuota", "quota", AttrInt, "ftp", false)
r.addAttr("gscFtpHomeDir", "homeDir", AttrString, "ftp", false)
r.addAttr("gscFtpUploadBandwidth", "uploadBandwidth", AttrInt, "ftp", false)
r.addAttr("gscFtpDownloadBandwidth", "downloadBandwidth", AttrInt, "ftp", false)
r.addAttr("gscFtpAllowedIPs", "allowedIPs", AttrStringMulti, "ftp", false)
// ── file (5) ────────────────────────────────────────────────────
r.addAttr("gscFileEnabled", "enabled", AttrBool, "file", false)
r.addAttr("gscFileQuota", "quota", AttrInt, "file", false)
r.addAttr("gscFileHomeDir", "homeDir", AttrString, "file", false)
r.addAttr("gscFileVersioning", "versioning", AttrBool, "file", false)
r.addAttr("gscFileMaxFileSize", "maxFileSize", AttrInt, "file", false)
// ── sharing (5) ────────────────────────────────────────────────
r.addAttr("gscShareEnabled", "enabled", AttrBool, "sharing", false)
r.addAttr("gscShareExternalEnabled", "externalEnabled", AttrBool, "sharing", false)
r.addAttr("gscShareMaxRecipients", "maxRecipients", AttrInt, "sharing", false)
r.addAttr("gscShareDefaultExpiry", "defaultExpiry", AttrInt, "sharing", false)
r.addAttr("gscSharePasswordRequired", "passwordRequired", AttrBool, "sharing", false)
// ── calendar (5) ───────────────────────────────────────────────
r.addAttr("gscCalEnabled", "enabled", AttrBool, "calendar", false)
r.addAttr("gscCalDefaultCalendar", "defaultCalendar", AttrString, "calendar", false)
r.addAttr("gscCalTimezone", "timezone", AttrString, "calendar", false)
r.addAttr("gscCalFreeBusyPublic", "freeBusyPublic", AttrBool, "calendar", false)
r.addAttr("gscCalDelegates", "delegates", AttrDNMulti, "calendar", false)
// ── telephony (8) ──────────────────────────────────────────────
r.addAttr("gscTelEnabled", "enabled", AttrBool, "telephony", false)
r.addAttr("gscTelExtension", "extension", AttrString, "telephony", false)
r.addAttr("gscTelDID", "did", AttrString, "telephony", false)
r.addAttr("gscTelVoicemailEnabled", "voicemailEnabled", AttrBool, "telephony", false)
r.addAttr("gscTelVoicemailPin", "voicemailPin", AttrString, "telephony", false)
r.addAttr("gscTelCallForward", "callForward", AttrString, "telephony", false)
r.addAttr("gscTelCallGroup", "callGroup", AttrString, "telephony", false)
r.addAttr("gscTelRecordCalls", "recordCalls", AttrBool, "telephony", false)
// ── contacts (4) ──────────────────────────────────────────────
r.addAttr("gscContactsEnabled", "enabled", AttrBool, "contacts", false)
r.addAttr("gscContactsShared", "shared", AttrBool, "contacts", false)
r.addAttr("gscContactsMaxContacts", "maxContacts", AttrInt, "contacts", false)
r.addAttr("gscContactsExportEnabled", "exportEnabled", AttrBool, "contacts", false)
// ── ai (5) ─────────────────────────────────────────────────────
r.addAttr("gscAIEnabled", "enabled", AttrBool, "ai", false)
r.addAttr("gscAIModel", "model", AttrString, "ai", false)
r.addAttr("gscAIMaxTokens", "maxTokens", AttrInt, "ai", false)
r.addAttr("gscAIFeatures", "features", AttrStringMulti, "ai", false)
r.addAttr("gscAIUsageQuota", "usageQuota", AttrInt, "ai", false)
// ── resource (9) ──────────────────────────────────────────────
r.addAttr("gscResourceId", "resourceId", AttrString, "resource", false)
r.addAttr("gscResourceName", "resourceName", AttrString, "resource", false)
r.addAttr("gscResourceType", "resourceType", AttrString, "resource", false)
r.addAttr("gscResourceEmail", "resourceEmail", AttrString, "resource", false)
r.addAttr("gscResourceCapacity", "capacity", AttrInt, "resource", false)
r.addAttr("gscResourceLocation", "location", AttrString, "resource", false)
r.addAttr("gscResourceBookable", "bookable", AttrBool, "resource", false)
r.addAttr("gscResourceApprovalRequired", "approvalRequired", AttrBool, "resource", false)
r.addAttr("gscResourceOwnerDN", "ownerDN", AttrDN, "resource", false)
// ── dlp (10) ──────────────────────────────────────────────────
r.addAttr("gscDlpEnabled", "enabled", AttrBool, "dlp", false)
r.addAttr("gscDlpPolicyDN", "policyDN", AttrDNMulti, "dlp", false)
r.addAttr("gscDlpExempt", "exempt", AttrBool, "dlp", false)
r.addAttr("gscDlpPolicyName", "policyName", AttrString, "dlp", false)
r.addAttr("gscDlpPolicyType", "policyType", AttrString, "dlp", false)
r.addAttr("gscDlpPolicyRules", "policyRules", AttrStringMulti, "dlp", false)
r.addAttr("gscDlpPolicyAction", "policyAction", AttrString, "dlp", false)
r.addAttr("gscDlpPolicyPriority", "policyPriority", AttrInt, "dlp", false)
r.addAttr("gscDlpPolicyScope", "policyScope", AttrStringMulti, "dlp", false)
r.addAttr("gscDlpPolicyStatus", "policyStatus", AttrString, "dlp", false)
// ── sensitivity (9) ──────────────────────────────────────────
r.addAttr("gscSensitivityEnabled", "enabled", AttrBool, "sensitivity", false)
r.addAttr("gscSensitivityDefaultLabel", "defaultLabel", AttrString, "sensitivity", false)
r.addAttr("gscSensitivityLabelName", "labelName", AttrString, "sensitivity", false)
r.addAttr("gscSensitivityLabelPriority", "labelPriority", AttrInt, "sensitivity", false)
r.addAttr("gscSensitivityLabelColor", "labelColor", AttrString, "sensitivity", false)
r.addAttr("gscSensitivityLabelTooltip", "labelTooltip", AttrString, "sensitivity", false)
r.addAttr("gscSensitivityLabelScope", "labelScope", AttrStringMulti, "sensitivity", false)
r.addAttr("gscSensitivityEncryptionRequired", "encryptionRequired", AttrBool, "sensitivity", false)
r.addAttr("gscSensitivityWatermark", "watermark", AttrBool, "sensitivity", false)
// ── encryption (8) ──────────────────────────────────────────
r.addAttr("gscEncryptionEnabled", "enabled", AttrBool, "encryption", false)
r.addAttr("gscEncryptionKeyDN", "keyDN", AttrDN, "encryption", false)
r.addAttr("gscEncryptionPolicyName", "policyName", AttrString, "encryption", false)
r.addAttr("gscEncryptionPolicyType", "policyType", AttrString, "encryption", false)
r.addAttr("gscEncryptionAlgorithm", "algorithm", AttrString, "encryption", false)
r.addAttr("gscEncryptionKeyLength", "keyLength", AttrInt, "encryption", false)
r.addAttr("gscEncryptionScope", "scope", AttrStringMulti, "encryption", false)
r.addAttr("gscEncryptionPolicyStatus", "policyStatus", AttrString, "encryption", false)
// ── retention (10) ──────────────────────────────────────────
r.addAttr("gscRetentionEnabled", "enabled", AttrBool, "retention", false)
r.addAttr("gscRetentionPolicyDN", "policyDN", AttrDNMulti, "retention", false)
r.addAttr("gscRetentionPolicyName", "policyName", AttrString, "retention", false)
r.addAttr("gscRetentionPolicyType", "policyType", AttrString, "retention", false)
r.addAttr("gscRetentionDuration", "duration", AttrInt, "retention", false)
r.addAttr("gscRetentionAction", "action", AttrString, "retention", false)
r.addAttr("gscRetentionScope", "scope", AttrStringMulti, "retention", false)
r.addAttr("gscRetentionPolicyStatus", "policyStatus", AttrString, "retention", false)
r.addAttr("gscRetentionExcludeFolders", "excludeFolders", AttrStringMulti, "retention", false)
r.addAttr("gscRetentionLegalHold", "legalHold", AttrBool, "retention", false)
// ── ediscovery (11) ─────────────────────────────────────────
r.addAttr("gscEDiscoveryEnabled", "enabled", AttrBool, "ediscovery", false)
r.addAttr("gscEDiscoveryCustodian", "custodian", AttrBool, "ediscovery", false)
r.addAttr("gscEDiscoveryCaseName", "caseName", AttrString, "ediscovery", false)
r.addAttr("gscEDiscoveryCaseStatus", "caseStatus", AttrString, "ediscovery", false)
r.addAttr("gscEDiscoveryCaseCreatedAt", "caseCreatedAt", AttrTime, "ediscovery", false)
r.addAttr("gscEDiscoveryCaseClosedAt", "caseClosedAt", AttrTime, "ediscovery", false)
r.addAttr("gscEDiscoveryHoldName", "holdName", AttrString, "ediscovery", false)
r.addAttr("gscEDiscoveryHoldScope", "holdScope", AttrStringMulti, "ediscovery", false)
r.addAttr("gscEDiscoveryHoldStatus", "holdStatus", AttrString, "ediscovery", false)
r.addAttr("gscEDiscoveryHoldCreatedAt", "holdCreatedAt", AttrTime, "ediscovery", false)
r.addAttr("gscEDiscoverySearchQuery", "searchQuery", AttrString, "ediscovery", false)
// ── audit (9) ───────────────────────────────────────────────
r.addAttr("gscAuditEnabled", "enabled", AttrBool, "audit", false)
r.addAttr("gscAuditLevel", "level", AttrString, "audit", false)
r.addAttr("gscAuditPolicyName", "policyName", AttrString, "audit", false)
r.addAttr("gscAuditPolicyScope", "policyScope", AttrStringMulti, "audit", false)
r.addAttr("gscAuditPolicyActions", "policyActions", AttrStringMulti, "audit", false)
r.addAttr("gscAuditPolicyStatus", "policyStatus", AttrString, "audit", false)
r.addAttr("gscAuditRetentionDays", "retentionDays", AttrInt, "audit", false)
r.addAttr("gscAuditAlertEnabled", "alertEnabled", AttrBool, "audit", false)
r.addAttr("gscAuditAlertRecipients", "alertRecipients", AttrStringMulti, "audit", false)
// ── iam (12) ────────────────────────────────────────────────
r.addAttr("gscIAMEnabled", "enabled", AttrBool, "iam", false)
r.addAttr("gscIAMMFARequired", "mfaRequired", AttrBool, "iam", false)
r.addAttr("gscIAMMFAMethod", "mfaMethod", AttrString, "iam", false)
r.addAttr("gscIAMPasswordPolicy", "passwordPolicy", AttrString, "iam", false)
r.addAttr("gscIAMSessionTimeout", "sessionTimeout", AttrInt, "iam", false)
r.addAttr("gscIAMMaxSessions", "maxSessions", AttrInt, "iam", false)
r.addAttr("gscIAMIPRestrictions", "ipRestrictions", AttrStringMulti, "iam", false)
r.addAttr("gscIAMRiskLevel", "riskLevel", AttrString, "iam", false)
r.addAttr("gscIAMCAPolicyName", "caPolicyName", AttrString, "iam", false)
r.addAttr("gscIAMCAPolicyConditions", "caPolicyConditions", AttrStringMulti, "iam", false)
r.addAttr("gscIAMCAPolicyActions", "caPolicyActions", AttrStringMulti, "iam", false)
r.addAttr("gscIAMCAPolicyStatus", "caPolicyStatus", AttrString, "iam", false)
// ── collaboration (10) ──────────────────────────────────────
r.addAttr("gscCollabEnabled", "enabled", AttrBool, "collaboration", false)
r.addAttr("gscCollabTeamsEnabled", "teamsEnabled", AttrBool, "collaboration", false)
r.addAttr("gscCollabChannelsEnabled", "channelsEnabled", AttrBool, "collaboration", false)
r.addAttr("gscCollabExternalEnabled", "externalEnabled", AttrBool, "collaboration", false)
r.addAttr("gscCollabPolicyName", "policyName", AttrString, "collaboration", false)
r.addAttr("gscCollabPolicyScope", "policyScope", AttrStringMulti, "collaboration", false)
r.addAttr("gscCollabPolicyActions", "policyActions", AttrStringMulti, "collaboration", false)
r.addAttr("gscCollabPolicyStatus", "policyStatus", AttrString, "collaboration", false)
r.addAttr("gscCollabMaxTeamSize", "maxTeamSize", AttrInt, "collaboration", false)
r.addAttr("gscCollabGuestAccessEnabled", "guestAccessEnabled", AttrBool, "collaboration", false)
// ── barriers (9) ───────────────────────────────────────────
r.addAttr("gscBarrierEnabled", "enabled", AttrBool, "barriers", false)
r.addAttr("gscBarrierSegmentDN", "segmentDN", AttrDNMulti, "barriers", false)
r.addAttr("gscBarrierSegmentName", "segmentName", AttrString, "barriers", false)
r.addAttr("gscBarrierSegmentMembers", "segmentMembers", AttrDNMulti, "barriers", false)
r.addAttr("gscBarrierPolicyName", "policyName", AttrString, "barriers", false)
r.addAttr("gscBarrierPolicyType", "policyType", AttrString, "barriers", false)
r.addAttr("gscBarrierPolicySegments", "policySegments", AttrStringMulti, "barriers", false)
r.addAttr("gscBarrierPolicyAction", "policyAction", AttrString, "barriers", false)
r.addAttr("gscBarrierPolicyStatus", "policyStatus", AttrString, "barriers", false)
// ── guest (26) ─────────────────────────────────────────────
// identity core
r.addAttr("gscGuestEnabled", "enabled", AttrBool, "guest", false)
r.addAttr("gscGuestType", "type", AttrString, "guest", false)
r.addAttr("gscGuestOrganization", "organization", AttrString, "guest", false)
r.addAttr("gscGuestExternalEmail", "externalEmail", AttrString, "guest", false)
r.addAttr("gscGuestVerified", "verified", AttrBool, "guest", false)
// federation
r.addAttr("gscGuestFederatedIdpDN", "federatedIdpDN", AttrDN, "guest", false)
r.addAttr("gscGuestFederatedId", "federatedId", AttrString, "guest", false)
r.addAttr("gscGuestIdpName", "idpName", AttrString, "guest", false)
r.addAttr("gscGuestIdpType", "idpType", AttrString, "guest", false)
r.addAttr("gscGuestIdpEntityId", "idpEntityId", AttrString, "guest", false)
r.addAttr("gscGuestIdpMetadataURL", "idpMetadataURL", AttrString, "guest", false)
r.addAttr("gscGuestIdpDomains", "idpDomains", AttrStringMulti, "guest", false)
r.addAttr("gscGuestIdpStatus", "idpStatus", AttrString, "guest", false)
// access control
r.addAttr("gscGuestAccessScope", "accessScope", AttrStringMulti, "guest", false)
r.addAttr("gscGuestPermissionLevel", "permissionLevel", AttrString, "guest", false)
r.addAttr("gscGuestResourceDN", "resourceDN", AttrDNMulti, "guest", false)
// invitation/audit
r.addAttr("gscGuestInvitedBy", "invitedBy", AttrDN, "guest", true)
r.addAttr("gscGuestInvitedAt", "invitedAt", AttrTime, "guest", true)
r.addAttr("gscGuestExpiresAt", "expiresAt", AttrTime, "guest", false)
r.addAttr("gscGuestLastAccessAt", "lastAccessAt", AttrTime, "guest", true)
r.addAttr("gscGuestAccessCount", "accessCount", AttrInt, "guest", true)
// policy/restrictions
r.addAttr("gscGuestPolicyName", "policyName", AttrString, "guest", false)
r.addAttr("gscGuestPolicyScope", "policyScope", AttrStringMulti, "guest", false)
r.addAttr("gscGuestPolicyAction", "policyAction", AttrString, "guest", false)
r.addAttr("gscGuestPolicyStatus", "policyStatus", AttrString, "guest", false)
r.addAttr("gscGuestMaxDuration", "maxDuration", AttrInt, "guest", false)
// ── kms-user (12) ──────────────────────────────────────────
r.addAttr("gscKmsEnabled", "enabled", AttrBool, "kms-user", false)
r.addAttr("gscKmsRole", "role", AttrString, "kms-user", false)
r.addAttr("gscKmsKeyAccess", "keyAccess", AttrStringMulti, "kms-user", false)
r.addAttr("gscKmsMaxKeys", "maxKeys", AttrInt, "kms-user", false)
r.addAttr("gscKmsAllowedAlgorithms", "allowedAlgorithms", AttrStringMulti, "kms-user", false)
r.addAttr("gscKmsAllowedOperations", "allowedOperations", AttrStringMulti, "kms-user", false)
r.addAttr("gscKmsApprovalRequired", "approvalRequired", AttrBool, "kms-user", false)
r.addAttr("gscKmsAuditEnabled", "auditEnabled", AttrBool, "kms-user", false)
r.addAttr("gscKmsLastKeyAccess", "lastKeyAccess", AttrTime, "kms-user", true)
r.addAttr("gscKmsKeyCount", "keyCount", AttrInt, "kms-user", true)
r.addAttr("gscKmsPolicyDN", "policyDN", AttrDNMulti, "kms-user", false)
r.addAttr("gscKmsHsmAccess", "hsmAccess", AttrBool, "kms-user", false)
// ── kms-tenant (12) ────────────────────────────────────────
r.addAttr("gscKmsTenantEnabled", "enabled", AttrBool, "kms-tenant", false)
r.addAttr("gscKmsTenantMaxKeys", "maxKeys", AttrInt, "kms-tenant", false)
r.addAttr("gscKmsTenantAllowedAlgorithms", "allowedAlgorithms", AttrStringMulti, "kms-tenant", false)
r.addAttr("gscKmsTenantKeyRotationDays", "keyRotationDays", AttrInt, "kms-tenant", false)
r.addAttr("gscKmsTenantHsmEnabled", "hsmEnabled", AttrBool, "kms-tenant", false)
r.addAttr("gscKmsTenantHsmPartition", "hsmPartition", AttrString, "kms-tenant", false)
r.addAttr("gscKmsTenantAutoRotate", "autoRotate", AttrBool, "kms-tenant", false)
r.addAttr("gscKmsTenantKeyCount", "keyCount", AttrInt, "kms-tenant", true)
r.addAttr("gscKmsTenantQuota", "quota", AttrInt, "kms-tenant", false)
r.addAttr("gscKmsTenantDefaultAlgorithm", "defaultAlgorithm", AttrString, "kms-tenant", false)
r.addAttr("gscKmsTenantDefaultKeyLength", "defaultKeyLength", AttrInt, "kms-tenant", false)
r.addAttr("gscKmsTenantPolicyDN", "policyDN", AttrDNMulti, "kms-tenant", false)
// ── managed-key (21) ───────────────────────────────────────
r.addAttr("gscKeyId", "keyId", AttrString, "managed-key", false)
r.addAttr("gscKeyName", "keyName", AttrString, "managed-key", false)
r.addAttr("gscKeyAlgorithm", "algorithm", AttrString, "managed-key", false)
r.addAttr("gscKeyLength", "keyLength", AttrInt, "managed-key", false)
r.addAttr("gscKeyType", "keyType", AttrString, "managed-key", false)
r.addAttr("gscKeyStatus", "status", AttrString, "managed-key", false)
r.addAttr("gscKeyCreatedAt", "createdAt", AttrTime, "managed-key", true)
r.addAttr("gscKeyExpiresAt", "expiresAt", AttrTime, "managed-key", false)
r.addAttr("gscKeyRotatedAt", "rotatedAt", AttrTime, "managed-key", true)
r.addAttr("gscKeyOwnerDN", "ownerDN", AttrDN, "managed-key", false)
r.addAttr("gscKeyTenantDN", "tenantDN", AttrDN, "managed-key", false)
r.addAttr("gscKeyOperations", "operations", AttrStringMulti, "managed-key", false)
r.addAttr("gscKeyMaterial", "material", AttrString, "managed-key", false)
r.addAttr("gscKeyPublicKey", "publicKey", AttrString, "managed-key", false)
r.addAttr("gscKeyFingerprint", "fingerprint", AttrString, "managed-key", true)
r.addAttr("gscKeyVersion", "version", AttrInt, "managed-key", false)
r.addAttr("gscKeyPreviousVersionDN", "previousVersionDN", AttrDN, "managed-key", false)
r.addAttr("gscKeyHsmBacked", "hsmBacked", AttrBool, "managed-key", false)
r.addAttr("gscKeyHsmSlot", "hsmSlot", AttrString, "managed-key", false)
r.addAttr("gscKeyAutoRotate", "autoRotate", AttrBool, "managed-key", false)
r.addAttr("gscKeyRotationDays", "rotationDays", AttrInt, "managed-key", false)
// ── kms-policy (15) ────────────────────────────────────────
r.addAttr("gscKmsPolicyId", "policyId", AttrString, "kms-policy", false)
r.addAttr("gscKmsPolicyName", "policyName", AttrString, "kms-policy", false)
r.addAttr("gscKmsPolicyType", "policyType", AttrString, "kms-policy", false)
r.addAttr("gscKmsPolicyEffect", "effect", AttrString, "kms-policy", false)
r.addAttr("gscKmsPolicyPrincipalDN", "principalDN", AttrDNMulti, "kms-policy", false)
r.addAttr("gscKmsPolicyResourceDN", "resourceDN", AttrDNMulti, "kms-policy", false)
r.addAttr("gscKmsPolicyOperations", "operations", AttrStringMulti, "kms-policy", false)
r.addAttr("gscKmsPolicyConditions", "conditions", AttrStringMulti, "kms-policy", false)
r.addAttr("gscKmsPolicyPriority", "priority", AttrInt, "kms-policy", false)
r.addAttr("gscKmsPolicyStatus", "status", AttrString, "kms-policy", false)
r.addAttr("gscKmsPolicyCreatedAt", "createdAt", AttrTime, "kms-policy", true)
r.addAttr("gscKmsPolicyModifiedAt", "modifiedAt", AttrTime, "kms-policy", true)
r.addAttr("gscKmsPolicyTenantDN", "tenantDN", AttrDN, "kms-policy", false)
r.addAttr("gscKmsPolicyMaxKeyAge", "maxKeyAge", AttrInt, "kms-policy", false)
r.addAttr("gscKmsPolicyRequireHsm", "requireHsm", AttrBool, "kms-policy", false)
// ── hsm-config (10) ────────────────────────────────────────
r.addAttr("gscHsmConfigId", "configId", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigName", "configName", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigType", "type", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigVendor", "vendor", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigModel", "model", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigConnectionString", "connectionString", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigSlots", "slots", AttrStringMulti, "hsm-config", false)
r.addAttr("gscHsmConfigStatus", "status", AttrString, "hsm-config", false)
r.addAttr("gscHsmConfigMaxKeys", "maxKeys", AttrInt, "hsm-config", false)
r.addAttr("gscHsmConfigTenantDN", "tenantDN", AttrDNMulti, "hsm-config", false)
}

112
internal/schema/entities.go Normal file
View File

@@ -0,0 +1,112 @@
package schema
// registerEntities registers all 18 entity type definitions for generic CRUD.
func (r *Registry) registerEntities() {
r.addEntityType("tenant", "Organization tenant",
[]string{"top", "gscTenant"},
"ou=tenants", "gscTenantId",
"(objectClass=gscTenant)", "tenant",
[]string{"gscTenantId", "gscTenantName"})
r.addEntityType("resource", "Shared resource (room, equipment)",
[]string{"top", "gscResource"},
"ou=tenants", "gscResourceId",
"(objectClass=gscResource)", "resource",
[]string{"gscResourceId", "gscResourceName", "gscResourceType"})
r.addEntityType("dlp-policy", "Data loss prevention policy",
[]string{"top", "gscDlpPolicy"},
"ou=dlp,ou=compliance", "cn",
"(objectClass=gscDlpPolicy)", "dlp",
[]string{"gscDlpPolicyName"})
r.addEntityType("sensitivity-label", "Sensitivity classification label",
[]string{"top", "gscSensitivityLabel"},
"ou=sensitivity,ou=compliance", "cn",
"(objectClass=gscSensitivityLabel)", "sensitivity",
[]string{"gscSensitivityLabelName"})
r.addEntityType("encryption-policy", "Encryption policy",
[]string{"top", "gscEncryptionPolicy"},
"ou=encryption,ou=compliance", "cn",
"(objectClass=gscEncryptionPolicy)", "encryption",
[]string{"gscEncryptionPolicyName"})
r.addEntityType("retention-policy", "Data retention policy",
[]string{"top", "gscRetentionPolicy"},
"ou=retention,ou=compliance", "cn",
"(objectClass=gscRetentionPolicy)", "retention",
[]string{"gscRetentionPolicyName"})
r.addEntityType("ediscovery-case", "eDiscovery case",
[]string{"top", "gscEDiscoveryCase"},
"ou=ediscovery,ou=compliance", "cn",
"(objectClass=gscEDiscoveryCase)", "ediscovery",
[]string{"gscEDiscoveryCaseName"})
r.addEntityType("ediscovery-hold", "eDiscovery legal hold",
[]string{"top", "gscEDiscoveryHold"},
"ou=ediscovery,ou=compliance", "cn",
"(objectClass=gscEDiscoveryHold)", "ediscovery",
[]string{"gscEDiscoveryHoldName"})
r.addEntityType("audit-policy", "Audit logging policy",
[]string{"top", "gscAuditPolicy"},
"ou=audit,ou=compliance", "cn",
"(objectClass=gscAuditPolicy)", "audit",
[]string{"gscAuditPolicyName"})
r.addEntityType("ca-policy", "Conditional access policy",
[]string{"top", "gscConditionalAccessPolicy"},
"ou=iam,ou=compliance", "cn",
"(objectClass=gscConditionalAccessPolicy)", "iam",
[]string{"gscIAMCAPolicyName"})
r.addEntityType("collab-policy", "Collaboration policy",
[]string{"top", "gscCollaborationPolicy"},
"ou=collaboration,ou=compliance", "cn",
"(objectClass=gscCollaborationPolicy)", "collaboration",
[]string{"gscCollabPolicyName"})
r.addEntityType("barrier-segment", "Information barrier segment",
[]string{"top", "gscBarrierSegment"},
"ou=barriers,ou=compliance", "cn",
"(objectClass=gscBarrierSegment)", "barriers",
[]string{"gscBarrierSegmentName"})
r.addEntityType("barrier-policy", "Information barrier policy",
[]string{"top", "gscBarrierPolicy"},
"ou=barriers,ou=compliance", "cn",
"(objectClass=gscBarrierPolicy)", "barriers",
[]string{"gscBarrierPolicyName"})
r.addEntityType("guest-policy", "Guest access policy",
[]string{"top", "gscGuestPolicy"},
"ou=policies,ou=guests", "cn",
"(objectClass=gscGuestPolicy)", "guest",
[]string{"gscGuestPolicyName"})
r.addEntityType("federated-idp", "Federated identity provider",
[]string{"top", "gscFederatedIdp"},
"ou=idps,ou=guests", "cn",
"(objectClass=gscFederatedIdp)", "guest",
[]string{"gscGuestIdpName"})
r.addEntityType("managed-key", "Managed encryption key",
[]string{"top", "gscManagedKey"},
"ou=keys,ou=keymanagement", "gscKeyId",
"(objectClass=gscManagedKey)", "managed-key",
[]string{"gscKeyId", "gscKeyName", "gscKeyAlgorithm"})
r.addEntityType("kms-policy", "Key management policy",
[]string{"top", "gscKmsPolicy"},
"ou=policies,ou=keymanagement", "gscKmsPolicyId",
"(objectClass=gscKmsPolicy)", "kms-policy",
[]string{"gscKmsPolicyId", "gscKmsPolicyName"})
r.addEntityType("hsm-config", "HSM hardware configuration",
[]string{"top", "gscHsmConfig"},
"ou=hsmconfigs,ou=keymanagement", "gscHsmConfigId",
"(objectClass=gscHsmConfig)", "hsm-config",
[]string{"gscHsmConfigId", "gscHsmConfigName"})
}

View File

@@ -0,0 +1,230 @@
package schema
// registerObjectClasses registers all 45 GoSec LDAP objectClass definitions.
func (r *Registry) registerObjectClasses() {
// ── AUXILIARY user objectClasses (21) ────────────────────────
r.addObjectClass("gscTenantUser", "AUXILIARY",
[]string{"gscTenantId"},
[]string{"gscTenantName", "gscTenantDomain", "gscTenantStatus", "gscUserTenantHash", "gscUserTenantHashSalt", "gscUserTenantHashVersion", "gscUserTenantHashCreatedAt", "gscUserTenantHashVerifiedAt", "gscCustomerId", "gscSID", "gscSIDCustomerPart", "gscSIDTenantPart", "gscSIDSpecial1", "gscSIDSpecial2", "gscSIDUserPart", "gscCreatedAt", "gscModifiedAt", "gscCreatedBy", "gscModifiedBy"},
"tenant")
r.addObjectClass("gscMailUser", "AUXILIARY",
[]string{"gscMailEnabled"},
[]string{"gscMailQuota", "gscMailAlias", "gscMailForward", "gscMailAutoReply", "gscMailAutoReplyMessage", "gscMailTransport", "gscMailDomain"},
"mail")
r.addObjectClass("gscConfUser", "AUXILIARY",
[]string{"gscConfEnabled"},
[]string{"gscConfRole", "gscConfMaxParticipants", "gscConfRecordingEnabled", "gscConfDefaultRoom"},
"conf")
r.addObjectClass("gscFtpUser", "AUXILIARY",
[]string{"gscFtpEnabled"},
[]string{"gscFtpQuota", "gscFtpHomeDir", "gscFtpUploadBandwidth", "gscFtpDownloadBandwidth", "gscFtpAllowedIPs"},
"ftp")
r.addObjectClass("gscFileUser", "AUXILIARY",
[]string{"gscFileEnabled"},
[]string{"gscFileQuota", "gscFileHomeDir", "gscFileVersioning", "gscFileMaxFileSize"},
"file")
r.addObjectClass("gscShareUser", "AUXILIARY",
[]string{"gscShareEnabled"},
[]string{"gscShareExternalEnabled", "gscShareMaxRecipients", "gscShareDefaultExpiry", "gscSharePasswordRequired"},
"sharing")
r.addObjectClass("gscCalUser", "AUXILIARY",
[]string{"gscCalEnabled"},
[]string{"gscCalDefaultCalendar", "gscCalTimezone", "gscCalFreeBusyPublic", "gscCalDelegates"},
"calendar")
r.addObjectClass("gscTelUser", "AUXILIARY",
[]string{"gscTelEnabled"},
[]string{"gscTelExtension", "gscTelDID", "gscTelVoicemailEnabled", "gscTelVoicemailPin", "gscTelCallForward", "gscTelCallGroup", "gscTelRecordCalls"},
"telephony")
r.addObjectClass("gscContactsUser", "AUXILIARY",
[]string{"gscContactsEnabled"},
[]string{"gscContactsShared", "gscContactsMaxContacts", "gscContactsExportEnabled"},
"contacts")
r.addObjectClass("gscAIUser", "AUXILIARY",
[]string{"gscAIEnabled"},
[]string{"gscAIModel", "gscAIMaxTokens", "gscAIFeatures", "gscAIUsageQuota"},
"ai")
r.addObjectClass("gscDlpUser", "AUXILIARY",
[]string{"gscDlpEnabled"},
[]string{"gscDlpPolicyDN", "gscDlpExempt"},
"dlp")
r.addObjectClass("gscSensitivityUser", "AUXILIARY",
[]string{"gscSensitivityEnabled"},
[]string{"gscSensitivityDefaultLabel"},
"sensitivity")
r.addObjectClass("gscEncryptionUser", "AUXILIARY",
[]string{"gscEncryptionEnabled"},
[]string{"gscEncryptionKeyDN"},
"encryption")
r.addObjectClass("gscRetentionUser", "AUXILIARY",
[]string{"gscRetentionEnabled"},
[]string{"gscRetentionPolicyDN"},
"retention")
r.addObjectClass("gscEDiscoveryUser", "AUXILIARY",
[]string{"gscEDiscoveryEnabled"},
[]string{"gscEDiscoveryCustodian"},
"ediscovery")
r.addObjectClass("gscAdvancedAuditUser", "AUXILIARY",
[]string{"gscAuditEnabled"},
[]string{"gscAuditLevel"},
"audit")
r.addObjectClass("gscIAMUser", "AUXILIARY",
[]string{"gscIAMEnabled"},
[]string{"gscIAMMFARequired", "gscIAMMFAMethod", "gscIAMPasswordPolicy", "gscIAMSessionTimeout", "gscIAMMaxSessions", "gscIAMIPRestrictions", "gscIAMRiskLevel"},
"iam")
r.addObjectClass("gscCollaborationUser", "AUXILIARY",
[]string{"gscCollabEnabled"},
[]string{"gscCollabTeamsEnabled", "gscCollabChannelsEnabled", "gscCollabExternalEnabled"},
"collaboration")
r.addObjectClass("gscBarrierUser", "AUXILIARY",
[]string{"gscBarrierEnabled"},
[]string{"gscBarrierSegmentDN"},
"barriers")
r.addObjectClass("gscGuestUser", "AUXILIARY",
[]string{"gscGuestEnabled"},
[]string{"gscGuestType", "gscGuestOrganization", "gscGuestExternalEmail", "gscGuestVerified", "gscGuestFederatedIdpDN", "gscGuestFederatedId", "gscGuestAccessScope", "gscGuestPermissionLevel", "gscGuestResourceDN", "gscGuestInvitedBy", "gscGuestInvitedAt", "gscGuestExpiresAt", "gscGuestLastAccessAt", "gscGuestAccessCount"},
"guest")
r.addObjectClass("gscKmsUser", "AUXILIARY",
[]string{"gscKmsEnabled"},
[]string{"gscKmsRole", "gscKmsKeyAccess", "gscKmsMaxKeys", "gscKmsAllowedAlgorithms", "gscKmsAllowedOperations", "gscKmsApprovalRequired", "gscKmsAuditEnabled", "gscKmsLastKeyAccess", "gscKmsKeyCount", "gscKmsPolicyDN", "gscKmsHsmAccess"},
"kms-user")
// ── STRUCTURAL entity objectClasses (18) ────────────────────
r.addObjectClass("gscTenant", "STRUCTURAL",
[]string{"gscTenantId", "gscTenantName"},
[]string{"gscTenantDomain", "gscTenantStatus", "gscTenantQuota", "gscTenantMaxUsers", "gscTenantCreatedAt", "gscTenantServices", "gscTenantAdminDN", "gscTenantParentDN", "gscDescription", "gscEnabled", "gscNotes", "gscCreatedAt", "gscModifiedAt", "gscCreatedBy", "gscModifiedBy"},
"tenant")
r.addObjectClass("gscResource", "STRUCTURAL",
[]string{"gscResourceId", "gscResourceName", "gscResourceType"},
[]string{"gscResourceEmail", "gscResourceCapacity", "gscResourceLocation", "gscResourceBookable", "gscResourceApprovalRequired", "gscResourceOwnerDN", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"resource")
r.addObjectClass("gscDlpPolicy", "STRUCTURAL",
[]string{"gscDlpPolicyName"},
[]string{"gscDlpPolicyType", "gscDlpPolicyRules", "gscDlpPolicyAction", "gscDlpPolicyPriority", "gscDlpPolicyScope", "gscDlpPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"dlp")
r.addObjectClass("gscSensitivityLabel", "STRUCTURAL",
[]string{"gscSensitivityLabelName"},
[]string{"gscSensitivityLabelPriority", "gscSensitivityLabelColor", "gscSensitivityLabelTooltip", "gscSensitivityLabelScope", "gscSensitivityEncryptionRequired", "gscSensitivityWatermark", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"sensitivity")
r.addObjectClass("gscEncryptionPolicy", "STRUCTURAL",
[]string{"gscEncryptionPolicyName"},
[]string{"gscEncryptionPolicyType", "gscEncryptionAlgorithm", "gscEncryptionKeyLength", "gscEncryptionScope", "gscEncryptionPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"encryption")
r.addObjectClass("gscRetentionPolicy", "STRUCTURAL",
[]string{"gscRetentionPolicyName"},
[]string{"gscRetentionPolicyType", "gscRetentionDuration", "gscRetentionAction", "gscRetentionScope", "gscRetentionPolicyStatus", "gscRetentionExcludeFolders", "gscRetentionLegalHold", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"retention")
r.addObjectClass("gscEDiscoveryCase", "STRUCTURAL",
[]string{"gscEDiscoveryCaseName"},
[]string{"gscEDiscoveryCaseStatus", "gscEDiscoveryCaseCreatedAt", "gscEDiscoveryCaseClosedAt", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"ediscovery")
r.addObjectClass("gscEDiscoveryHold", "STRUCTURAL",
[]string{"gscEDiscoveryHoldName"},
[]string{"gscEDiscoveryHoldScope", "gscEDiscoveryHoldStatus", "gscEDiscoveryHoldCreatedAt", "gscEDiscoverySearchQuery", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"ediscovery")
r.addObjectClass("gscAuditPolicy", "STRUCTURAL",
[]string{"gscAuditPolicyName"},
[]string{"gscAuditPolicyScope", "gscAuditPolicyActions", "gscAuditPolicyStatus", "gscAuditRetentionDays", "gscAuditAlertEnabled", "gscAuditAlertRecipients", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"audit")
r.addObjectClass("gscConditionalAccessPolicy", "STRUCTURAL",
[]string{"gscIAMCAPolicyName"},
[]string{"gscIAMCAPolicyConditions", "gscIAMCAPolicyActions", "gscIAMCAPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"iam")
r.addObjectClass("gscCollaborationPolicy", "STRUCTURAL",
[]string{"gscCollabPolicyName"},
[]string{"gscCollabPolicyScope", "gscCollabPolicyActions", "gscCollabPolicyStatus", "gscCollabMaxTeamSize", "gscCollabGuestAccessEnabled", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"collaboration")
r.addObjectClass("gscBarrierSegment", "STRUCTURAL",
[]string{"gscBarrierSegmentName"},
[]string{"gscBarrierSegmentMembers", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"barriers")
r.addObjectClass("gscBarrierPolicy", "STRUCTURAL",
[]string{"gscBarrierPolicyName"},
[]string{"gscBarrierPolicyType", "gscBarrierPolicySegments", "gscBarrierPolicyAction", "gscBarrierPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"barriers")
r.addObjectClass("gscGuestPolicy", "STRUCTURAL",
[]string{"gscGuestPolicyName"},
[]string{"gscGuestPolicyScope", "gscGuestPolicyAction", "gscGuestPolicyStatus", "gscGuestMaxDuration", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"guest")
r.addObjectClass("gscFederatedIdp", "STRUCTURAL",
[]string{"gscGuestIdpName"},
[]string{"gscGuestIdpType", "gscGuestIdpEntityId", "gscGuestIdpMetadataURL", "gscGuestIdpDomains", "gscGuestIdpStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
"guest")
r.addObjectClass("gscManagedKey", "STRUCTURAL",
[]string{"gscKeyId", "gscKeyName", "gscKeyAlgorithm"},
[]string{"gscKeyLength", "gscKeyType", "gscKeyStatus", "gscKeyCreatedAt", "gscKeyExpiresAt", "gscKeyRotatedAt", "gscKeyOwnerDN", "gscKeyTenantDN", "gscKeyOperations", "gscKeyMaterial", "gscKeyPublicKey", "gscKeyFingerprint", "gscKeyVersion", "gscKeyPreviousVersionDN", "gscKeyHsmBacked", "gscKeyHsmSlot", "gscKeyAutoRotate", "gscKeyRotationDays"},
"managed-key")
r.addObjectClass("gscKmsPolicy", "STRUCTURAL",
[]string{"gscKmsPolicyId", "gscKmsPolicyName"},
[]string{"gscKmsPolicyType", "gscKmsPolicyEffect", "gscKmsPolicyPrincipalDN", "gscKmsPolicyResourceDN", "gscKmsPolicyOperations", "gscKmsPolicyConditions", "gscKmsPolicyPriority", "gscKmsPolicyStatus", "gscKmsPolicyCreatedAt", "gscKmsPolicyModifiedAt", "gscKmsPolicyTenantDN", "gscKmsPolicyMaxKeyAge", "gscKmsPolicyRequireHsm"},
"kms-policy")
r.addObjectClass("gscHsmConfig", "STRUCTURAL",
[]string{"gscHsmConfigId", "gscHsmConfigName"},
[]string{"gscHsmConfigType", "gscHsmConfigVendor", "gscHsmConfigModel", "gscHsmConfigConnectionString", "gscHsmConfigSlots", "gscHsmConfigStatus", "gscHsmConfigMaxKeys", "gscHsmConfigTenantDN"},
"hsm-config")
// ── AUXILIARY object objectClasses (6) ──────────────────────
r.addObjectClass("gscAuditObject", "AUXILIARY",
[]string{"gscAuditEnabled"},
[]string{"gscAuditLevel", "gscAuditPolicyName"},
"audit")
r.addObjectClass("gscMeetingRoom", "AUXILIARY",
[]string{"gscResourceId", "gscResourceType"},
[]string{"gscResourceCapacity", "gscResourceLocation", "gscResourceBookable"},
"resource")
r.addObjectClass("gscSharedMailbox", "AUXILIARY",
[]string{"gscMailEnabled"},
[]string{"gscMailQuota", "gscMailAlias", "gscMailDomain"},
"mail")
r.addObjectClass("gscEquipment", "AUXILIARY",
[]string{"gscResourceId", "gscResourceType"},
[]string{"gscResourceName", "gscResourceLocation", "gscResourceBookable"},
"resource")
r.addObjectClass("gscKmsTenant", "AUXILIARY",
[]string{"gscKmsTenantEnabled"},
[]string{"gscKmsTenantMaxKeys", "gscKmsTenantAllowedAlgorithms", "gscKmsTenantKeyRotationDays", "gscKmsTenantHsmEnabled", "gscKmsTenantHsmPartition", "gscKmsTenantAutoRotate", "gscKmsTenantKeyCount", "gscKmsTenantQuota", "gscKmsTenantDefaultAlgorithm", "gscKmsTenantDefaultKeyLength", "gscKmsTenantPolicyDN"},
"kms-tenant")
}

273
internal/schema/registry.go Normal file
View File

@@ -0,0 +1,273 @@
package schema
import (
"fmt"
"strconv"
"strings"
"time"
)
// Registry is the central schema registry for all GoSec LDAP attributes,
// objectClasses, and entity types.
type Registry struct {
attrs map[string]*AttrDef // ldapName → AttrDef
attrsByJSON map[string]*AttrDef // "domain:jsonName" → AttrDef
domainAttrs map[string][]*AttrDef // domain → list of attrs
objectClasses map[string]*ObjectClassDef // OC name → ObjectClassDef
domainOC map[string]string // domain → auxiliary user OC name
entityTypes map[string]*EntityTypeDef // entity name → EntityTypeDef
}
// NewRegistry creates and populates the schema registry
func NewRegistry() *Registry {
r := &Registry{
attrs: make(map[string]*AttrDef),
attrsByJSON: make(map[string]*AttrDef),
domainAttrs: make(map[string][]*AttrDef),
objectClasses: make(map[string]*ObjectClassDef),
domainOC: make(map[string]string),
entityTypes: make(map[string]*EntityTypeDef),
}
r.registerAttributes()
r.registerObjectClasses()
r.registerEntities()
return r
}
func (r *Registry) addAttr(ldapName, jsonName string, typ AttrType, domain string, readOnly bool) {
def := &AttrDef{
LDAPName: ldapName,
JSONName: jsonName,
Type: typ,
Domain: domain,
ReadOnly: readOnly,
}
r.attrs[ldapName] = def
r.attrsByJSON[domain+":"+jsonName] = def
r.domainAttrs[domain] = append(r.domainAttrs[domain], def)
}
func (r *Registry) addObjectClass(name, kind string, must, may []string, domain string) {
r.objectClasses[name] = &ObjectClassDef{
Name: name,
Kind: kind,
Must: must,
May: may,
Domain: domain,
}
// Map domain → auxiliary user objectClass (first AUXILIARY wins)
if kind == "AUXILIARY" {
if _, exists := r.domainOC[domain]; !exists {
r.domainOC[domain] = name
}
}
}
func (r *Registry) addEntityType(name, description string, objectClasses []string, baseDN, rdnAttr, searchFilter, domain string, requiredAttrs []string) {
r.entityTypes[name] = &EntityTypeDef{
Name: name,
Description: description,
ObjectClasses: objectClasses,
BaseDN: baseDN,
RDNAttribute: rdnAttr,
SearchFilter: searchFilter,
Domain: domain,
RequiredAttrs: requiredAttrs,
}
}
// GetAttr returns an attribute definition by LDAP name
func (r *Registry) GetAttr(ldapName string) *AttrDef {
return r.attrs[ldapName]
}
// GetAttrByJSON returns an attribute definition by domain and JSON name
func (r *Registry) GetAttrByJSON(domain, jsonName string) *AttrDef {
return r.attrsByJSON[domain+":"+jsonName]
}
// AttrsForDomain returns all attribute definitions for a domain
func (r *Registry) AttrsForDomain(domain string) []*AttrDef {
return r.domainAttrs[domain]
}
// AllDomains returns all registered domain names
func (r *Registry) AllDomains() []string {
domains := make([]string, 0, len(r.domainAttrs))
for d := range r.domainAttrs {
domains = append(domains, d)
}
return domains
}
// AllUserAttrs returns all gsc* LDAP attribute names for user search
func (r *Registry) AllUserAttrs() []string {
attrs := make([]string, 0, len(r.attrs))
for name := range r.attrs {
attrs = append(attrs, name)
}
return attrs
}
// UserOCForDomain returns the auxiliary objectClass name for a user service domain
func (r *Registry) UserOCForDomain(domain string) string {
return r.domainOC[domain]
}
// RequiredOCsForAttrs determines which objectClasses are needed for a set of LDAP attributes
func (r *Registry) RequiredOCsForAttrs(ldapAttrNames []string) []string {
needed := make(map[string]bool)
attrSet := make(map[string]bool, len(ldapAttrNames))
for _, a := range ldapAttrNames {
attrSet[a] = true
}
for _, oc := range r.objectClasses {
if oc.Kind != "AUXILIARY" {
continue
}
for _, must := range oc.Must {
if attrSet[must] {
needed[oc.Name] = true
break
}
}
if needed[oc.Name] {
continue
}
for _, may := range oc.May {
if attrSet[may] {
needed[oc.Name] = true
break
}
}
}
result := make([]string, 0, len(needed))
for name := range needed {
result = append(result, name)
}
return result
}
// GetObjectClass returns an objectClass definition by name
func (r *Registry) GetObjectClass(name string) *ObjectClassDef {
return r.objectClasses[name]
}
// LDAPValueToGo converts LDAP string values to Go typed values based on attribute type
func (r *Registry) LDAPValueToGo(attr *AttrDef, values []string) interface{} {
if len(values) == 0 {
return nil
}
switch attr.Type {
case AttrString, AttrDN:
return values[0]
case AttrStringMulti, AttrDNMulti:
return values
case AttrInt:
if v, err := strconv.Atoi(values[0]); err == nil {
return v
}
return values[0]
case AttrBool:
return strings.EqualFold(values[0], "TRUE")
case AttrTime:
// GeneralizedTime format: 20060102150405Z
if t, err := time.Parse("20060102150405Z", values[0]); err == nil {
return t.Format(time.RFC3339)
}
return values[0]
default:
return values[0]
}
}
// GoValueToLDAP converts a Go value to LDAP string(s) based on attribute type
func (r *Registry) GoValueToLDAP(attr *AttrDef, value interface{}) ([]string, error) {
if value == nil {
return nil, nil
}
switch attr.Type {
case AttrString, AttrDN:
s, ok := value.(string)
if !ok {
return nil, fmt.Errorf("attribute %s expects string, got %T", attr.LDAPName, value)
}
return []string{s}, nil
case AttrStringMulti, AttrDNMulti:
switch v := value.(type) {
case []string:
return v, nil
case []interface{}:
result := make([]string, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("attribute %s expects string array, got %T in array", attr.LDAPName, item)
}
result = append(result, s)
}
return result, nil
default:
return nil, fmt.Errorf("attribute %s expects string array, got %T", attr.LDAPName, value)
}
case AttrInt:
switch v := value.(type) {
case float64:
return []string{strconv.Itoa(int(v))}, nil
case int:
return []string{strconv.Itoa(v)}, nil
case string:
return []string{v}, nil
default:
return nil, fmt.Errorf("attribute %s expects int, got %T", attr.LDAPName, value)
}
case AttrBool:
switch v := value.(type) {
case bool:
if v {
return []string{"TRUE"}, nil
}
return []string{"FALSE"}, nil
case string:
return []string{strings.ToUpper(v)}, nil
default:
return nil, fmt.Errorf("attribute %s expects bool, got %T", attr.LDAPName, value)
}
case AttrTime:
s, ok := value.(string)
if !ok {
return nil, fmt.Errorf("attribute %s expects time string, got %T", attr.LDAPName, value)
}
// Accept RFC3339 and convert to GeneralizedTime
if t, err := time.Parse(time.RFC3339, s); err == nil {
return []string{t.UTC().Format("20060102150405Z")}, nil
}
// Already GeneralizedTime format
return []string{s}, nil
default:
s, ok := value.(string)
if !ok {
return nil, fmt.Errorf("attribute %s: unsupported type %T", attr.LDAPName, value)
}
return []string{s}, nil
}
}
// GetEntityType returns an entity type definition by name
func (r *Registry) GetEntityType(name string) *EntityTypeDef {
return r.entityTypes[name]
}
// AllEntityTypes returns all registered entity type definitions
func (r *Registry) AllEntityTypes() map[string]*EntityTypeDef {
return r.entityTypes
}

44
internal/schema/types.go Normal file
View File

@@ -0,0 +1,44 @@
package schema
// AttrType represents the LDAP attribute value type
type AttrType int
const (
AttrString AttrType = iota // Single-value string
AttrStringMulti // Multi-value string
AttrInt // Integer (stored as string in LDAP)
AttrBool // Boolean (TRUE/FALSE in LDAP)
AttrDN // DN single
AttrDNMulti // DN multi
AttrTime // GeneralizedTime
)
// AttrDef defines an LDAP attribute with its JSON mapping and type info
type AttrDef struct {
LDAPName string // LDAP attribute name (e.g. "gscMailEnabled")
JSONName string // JSON field name (e.g. "enabled")
Type AttrType // Value type for conversion
Domain string // Service domain (e.g. "mail", "calendar")
ReadOnly bool // If true, not settable via API
}
// ObjectClassDef defines an LDAP objectClass
type ObjectClassDef struct {
Name string // ObjectClass name (e.g. "gscMailUser")
Kind string // "AUXILIARY" or "STRUCTURAL"
Must []string // Required LDAP attributes
May []string // Optional LDAP attributes
Domain string // Service domain this OC belongs to
}
// EntityTypeDef defines a standalone LDAP entity type for CRUD operations
type EntityTypeDef struct {
Name string // URL-safe name (e.g. "tenant", "dlp-policy")
Description string // Human-readable description
ObjectClasses []string // Required objectClasses
BaseDN string // Relative base DN (appended to LDAP base)
RDNAttribute string // Attribute used as RDN (e.g. "cn", "gscTenantId")
SearchFilter string // LDAP search filter for listing
Domain string // Logical domain grouping
RequiredAttrs []string // Attributes required on create
}

449
internal/service/carddav.go Normal file
View File

@@ -0,0 +1,449 @@
package service
import (
"context"
"crypto/md5"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// CardDAVService handles CardDAV principal, address book, and contact operations
type CardDAVService struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewCardDAVService creates a new CardDAV service
func NewCardDAVService(pool *pgxpool.Pool, logger zerolog.Logger) *CardDAVService {
return &CardDAVService{
pool: pool,
logger: logger.With().Str("service", "carddav").Logger(),
}
}
// --- Principals ---
// ListPrincipals lists all principals
func (s *CardDAVService) ListPrincipals(ctx context.Context) ([]types.CardDAVPrincipal, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, uri, email, displayname FROM principals ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
principals := make([]types.CardDAVPrincipal, 0)
for rows.Next() {
var p types.CardDAVPrincipal
var email, displayName *string
if err := rows.Scan(&p.ID, &p.URI, &email, &displayName); err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
if email != nil {
p.Email = *email
}
if displayName != nil {
p.DisplayName = *displayName
}
principals = append(principals, p)
}
return principals, nil
}
// GetPrincipal gets a principal by username
func (s *CardDAVService) GetPrincipal(ctx context.Context, username string) (*types.CardDAVPrincipal, error) {
uri := "principals/" + username
var p types.CardDAVPrincipal
var email, displayName *string
err := s.pool.QueryRow(ctx,
`SELECT id, uri, email, displayname FROM principals WHERE uri = $1`, uri).
Scan(&p.ID, &p.URI, &email, &displayName)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("query failed: %w", err)
}
if email != nil {
p.Email = *email
}
if displayName != nil {
p.DisplayName = *displayName
}
return &p, nil
}
// CreatePrincipal creates a new principal
func (s *CardDAVService) CreatePrincipal(ctx context.Context, req *types.CardDAVPrincipalCreate) (*types.CardDAVPrincipal, error) {
uri := "principals/" + req.Username
var id int
err := s.pool.QueryRow(ctx,
`INSERT INTO principals (uri, email, displayname) VALUES ($1, $2, $3) RETURNING id`,
uri, nilIfEmpty(req.Email), nilIfEmpty(req.DisplayName)).Scan(&id)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
s.logger.Info().Str("username", req.Username).Int("id", id).Msg("Created principal")
return s.GetPrincipal(ctx, req.Username)
}
// DeletePrincipal deletes a principal and cascades to address books and contacts
func (s *CardDAVService) DeletePrincipal(ctx context.Context, username string) error {
uri := "principals/" + username
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx failed: %w", err)
}
defer tx.Rollback(ctx)
// Delete contacts and changes for all address books owned by this principal
_, err = tx.Exec(ctx,
`DELETE FROM cards WHERE addressbookid IN (SELECT id FROM addressbooks WHERE principaluri = $1)`, uri)
if err != nil {
return fmt.Errorf("delete contacts failed: %w", err)
}
_, err = tx.Exec(ctx,
`DELETE FROM addressbookchanges WHERE addressbookid IN (SELECT id FROM addressbooks WHERE principaluri = $1)`, uri)
if err != nil {
return fmt.Errorf("delete changes failed: %w", err)
}
// Delete address books
_, err = tx.Exec(ctx,
`DELETE FROM addressbooks WHERE principaluri = $1`, uri)
if err != nil {
return fmt.Errorf("delete addressbooks failed: %w", err)
}
// Delete principal
ct, err := tx.Exec(ctx, `DELETE FROM principals WHERE uri = $1`, uri)
if err != nil {
return fmt.Errorf("delete principal failed: %w", err)
}
if ct.RowsAffected() == 0 {
return fmt.Errorf("principal not found")
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
s.logger.Info().Str("username", username).Msg("Deleted principal with cascade")
return nil
}
// --- Address Books ---
// ListAddressBooks lists address books, optionally filtered by principal
func (s *CardDAVService) ListAddressBooks(ctx context.Context, principal string) ([]types.AddressBook, error) {
query := `SELECT id, principaluri, displayname, uri, description, synctoken FROM addressbooks`
args := []interface{}{}
if principal != "" {
query += ` WHERE principaluri = $1`
args = append(args, "principals/"+principal)
}
query += ` ORDER BY id`
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
books := make([]types.AddressBook, 0)
for rows.Next() {
var ab types.AddressBook
var description *string
if err := rows.Scan(&ab.ID, &ab.PrincipalURI, &ab.DisplayName, &ab.URI, &description, &ab.SyncToken); err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
if description != nil {
ab.Description = *description
}
books = append(books, ab)
}
return books, nil
}
// GetAddressBook gets an address book by ID
func (s *CardDAVService) GetAddressBook(ctx context.Context, id int) (*types.AddressBook, error) {
var ab types.AddressBook
var description *string
err := s.pool.QueryRow(ctx,
`SELECT id, principaluri, displayname, uri, description, synctoken FROM addressbooks WHERE id = $1`, id).
Scan(&ab.ID, &ab.PrincipalURI, &ab.DisplayName, &ab.URI, &description, &ab.SyncToken)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("query failed: %w", err)
}
if description != nil {
ab.Description = *description
}
return &ab, nil
}
// CreateAddressBook creates a new address book
func (s *CardDAVService) CreateAddressBook(ctx context.Context, req *types.AddressBookCreate) (*types.AddressBook, error) {
var id int
err := s.pool.QueryRow(ctx,
`INSERT INTO addressbooks (principaluri, displayname, uri, description, synctoken)
VALUES ($1, $2, $3, $4, 1) RETURNING id`,
req.PrincipalURI, req.DisplayName, req.URI, nilIfEmpty(req.Description)).Scan(&id)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
s.logger.Info().Int("id", id).Str("uri", req.URI).Msg("Created address book")
return s.GetAddressBook(ctx, id)
}
// UpdateAddressBook updates an address book
func (s *CardDAVService) UpdateAddressBook(ctx context.Context, id int, req *types.AddressBookUpdate) (*types.AddressBook, error) {
setClauses := []string{}
args := []interface{}{}
argIdx := 1
if req.DisplayName != nil {
setClauses = append(setClauses, fmt.Sprintf("displayname = $%d", argIdx))
args = append(args, *req.DisplayName)
argIdx++
}
if req.Description != nil {
setClauses = append(setClauses, fmt.Sprintf("description = $%d", argIdx))
args = append(args, *req.Description)
argIdx++
}
if len(setClauses) == 0 {
return s.GetAddressBook(ctx, id)
}
args = append(args, id)
query := fmt.Sprintf("UPDATE addressbooks SET %s WHERE id = $%d",
join(setClauses, ", "), argIdx)
ct, err := s.pool.Exec(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
if ct.RowsAffected() == 0 {
return nil, nil
}
return s.GetAddressBook(ctx, id)
}
// DeleteAddressBook deletes an address book and its contacts
func (s *CardDAVService) DeleteAddressBook(ctx context.Context, id int) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx failed: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `DELETE FROM cards WHERE addressbookid = $1`, id)
if err != nil {
return fmt.Errorf("delete contacts failed: %w", err)
}
_, err = tx.Exec(ctx, `DELETE FROM addressbookchanges WHERE addressbookid = $1`, id)
if err != nil {
return fmt.Errorf("delete changes failed: %w", err)
}
ct, err := tx.Exec(ctx, `DELETE FROM addressbooks WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete addressbook failed: %w", err)
}
if ct.RowsAffected() == 0 {
return fmt.Errorf("address book not found")
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
s.logger.Info().Int("id", id).Msg("Deleted address book with contacts")
return nil
}
// --- Contacts ---
// ListContacts lists contacts in an address book (metadata only, no carddata)
func (s *CardDAVService) ListContacts(ctx context.Context, addressBookID int) ([]types.Contact, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, addressbookid, uri, lastmodified, etag, size
FROM cards WHERE addressbookid = $1 ORDER BY id`, addressBookID)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
contacts := make([]types.Contact, 0)
for rows.Next() {
var c types.Contact
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.LastModified, &c.ETag, &c.Size); err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
contacts = append(contacts, c)
}
return contacts, nil
}
// GetContact gets a contact by address book ID and URI (returns full carddata)
func (s *CardDAVService) GetContact(ctx context.Context, addressBookID int, uri string) (*types.Contact, error) {
var c types.Contact
var cardData []byte
err := s.pool.QueryRow(ctx,
`SELECT id, addressbookid, carddata, uri, lastmodified, etag, size
FROM cards WHERE addressbookid = $1 AND uri = $2`, addressBookID, uri).
Scan(&c.ID, &c.AddressBookID, &cardData, &c.URI, &c.LastModified, &c.ETag, &c.Size)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("query failed: %w", err)
}
c.CardData = string(cardData)
return &c, nil
}
// CreateContact creates a new contact in an address book
func (s *CardDAVService) CreateContact(ctx context.Context, addressBookID int, req *types.ContactCreate) (*types.Contact, error) {
etag := computeETag(req.CardData)
size := len(req.CardData)
lastModified := int(time.Now().Unix())
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx failed: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx,
`INSERT INTO cards (addressbookid, carddata, uri, lastmodified, etag, size)
VALUES ($1, $2, $3, $4, $5, $6)`,
addressBookID, []byte(req.CardData), req.URI, lastModified, etag, size)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
// Record change and bump sync token (operation 1 = add)
if err := addChange(ctx, tx, addressBookID, req.URI, 1); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit failed: %w", err)
}
s.logger.Info().Int("addressbookId", addressBookID).Str("uri", req.URI).Msg("Created contact")
return s.GetContact(ctx, addressBookID, req.URI)
}
// UpdateContact updates a contact's vCard data
func (s *CardDAVService) UpdateContact(ctx context.Context, addressBookID int, uri string, req *types.ContactUpdate) (*types.Contact, error) {
etag := computeETag(req.CardData)
size := len(req.CardData)
lastModified := int(time.Now().Unix())
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx failed: %w", err)
}
defer tx.Rollback(ctx)
ct, err := tx.Exec(ctx,
`UPDATE cards SET carddata = $1, lastmodified = $2, etag = $3, size = $4
WHERE addressbookid = $5 AND uri = $6`,
[]byte(req.CardData), lastModified, etag, size, addressBookID, uri)
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
if ct.RowsAffected() == 0 {
return nil, nil
}
// Record change and bump sync token (operation 2 = modify)
if err := addChange(ctx, tx, addressBookID, uri, 2); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit failed: %w", err)
}
s.logger.Info().Int("addressbookId", addressBookID).Str("uri", uri).Msg("Updated contact")
return s.GetContact(ctx, addressBookID, uri)
}
// DeleteContact deletes a contact from an address book
func (s *CardDAVService) DeleteContact(ctx context.Context, addressBookID int, uri string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx failed: %w", err)
}
defer tx.Rollback(ctx)
ct, err := tx.Exec(ctx,
`DELETE FROM cards WHERE addressbookid = $1 AND uri = $2`, addressBookID, uri)
if err != nil {
return fmt.Errorf("delete failed: %w", err)
}
if ct.RowsAffected() == 0 {
return fmt.Errorf("contact not found")
}
// Record change and bump sync token (operation 3 = delete)
if err := addChange(ctx, tx, addressBookID, uri, 3); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
s.logger.Info().Int("addressbookId", addressBookID).Str("uri", uri).Msg("Deleted contact")
return nil
}
// addChange records a change in addressbookchanges and bumps the sync token.
// This is critical for CardDAV sync — without it, clients won't see incremental changes.
// Operations: 1=add, 2=modify, 3=delete
func addChange(ctx context.Context, tx pgx.Tx, addressBookID int, uri string, operation int) error {
_, err := tx.Exec(ctx,
`INSERT INTO addressbookchanges (uri, synctoken, addressbookid, operation)
SELECT $1, synctoken, $2, $3 FROM addressbooks WHERE id = $2`,
uri, addressBookID, operation)
if err != nil {
return fmt.Errorf("record change failed: %w", err)
}
_, err = tx.Exec(ctx,
`UPDATE addressbooks SET synctoken = synctoken + 1 WHERE id = $1`, addressBookID)
if err != nil {
return fmt.Errorf("bump synctoken failed: %w", err)
}
return nil
}
// computeETag computes the ETag for card data (raw MD5 hex, matching sabre/dav DB format)
func computeETag(cardData string) string {
hash := md5.Sum([]byte(cardData))
return fmt.Sprintf("%x", hash)
}

View File

@@ -0,0 +1,161 @@
package service
import (
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// CertificateService handles EJBCA certificate operations
type CertificateService struct {
client *client.EJBCAClient
logger zerolog.Logger
}
// NewCertificateService creates a new certificate service
func NewCertificateService(ejbcaClient *client.EJBCAClient, logger zerolog.Logger) *CertificateService {
return &CertificateService{
client: ejbcaClient,
logger: logger.With().Str("service", "certificate").Logger(),
}
}
// ListCertificates searches for certificates
func (s *CertificateService) ListCertificates(search string, limit int) ([]types.Certificate, error) {
if limit <= 0 {
limit = 50
}
criteria := []client.CertSearchCriterion{}
if search != "" {
criteria = append(criteria, client.CertSearchCriterion{
Property: "QUERY",
Value: search,
Operation: "LIKE",
})
}
certs, err := s.client.SearchCertificates(&client.CertSearchRequest{
MaxResults: limit,
Criteria: criteria,
})
if err != nil {
return nil, err
}
result := make([]types.Certificate, 0, len(certs))
for _, c := range certs {
cert := types.Certificate{
SerialNumber: c.SerialNumber,
SubjectDN: c.SubjectDN,
IssuerDN: c.IssuerDN,
Status: c.Status,
CAName: c.CAName,
}
if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil {
cert.NotBefore = t
}
if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil {
cert.NotAfter = t
}
result = append(result, cert)
}
return result, nil
}
// GetCertificate gets a certificate by serial number
func (s *CertificateService) GetCertificate(serialNumber, issuerDN string) (*types.Certificate, error) {
c, err := s.client.GetCertificate(issuerDN, serialNumber)
if err != nil {
return nil, err
}
cert := &types.Certificate{
SerialNumber: c.SerialNumber,
SubjectDN: c.SubjectDN,
IssuerDN: c.IssuerDN,
Status: c.Status,
CAName: c.CAName,
}
if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil {
cert.NotBefore = t
}
if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil {
cert.NotAfter = t
}
return cert, nil
}
// RequestCertificate requests a new certificate from EJBCA
func (s *CertificateService) RequestCertificate(req *types.CertRequest) (*types.Certificate, error) {
san := buildSANString(req.SubjectDN, req.SANs)
enrollReq := &client.CertEnrollRequest{
CertificateProfileName: req.CertProfileName,
EndEntityProfileName: req.EndEntityName,
CAName: req.CAName,
Username: req.EndEntityName,
Password: "internal",
IncludeChain: true,
SubjectAltName: san,
}
c, err := s.client.EnrollCertificate(enrollReq)
if err != nil {
return nil, err
}
cert := &types.Certificate{
SerialNumber: c.SerialNumber,
SubjectDN: c.SubjectDN,
IssuerDN: c.IssuerDN,
Status: c.Status,
}
return cert, nil
}
// buildSANString builds an EJBCA-format SAN string (e.g. "dNSName=foo,dNSName=bar").
// If sans is empty, extracts CN from subjectDN as a fallback DNS SAN.
func buildSANString(subjectDN string, sans []string) string {
if len(sans) > 0 {
parts := make([]string, 0, len(sans))
for _, s := range sans {
if s != "" {
parts = append(parts, fmt.Sprintf("dNSName=%s", s))
}
}
return strings.Join(parts, ",")
}
// Fallback: extract CN from SubjectDN and use as DNS SAN
cn := extractCN(subjectDN)
if cn != "" {
return fmt.Sprintf("dNSName=%s", cn)
}
return ""
}
// extractCN extracts the CN value from a SubjectDN string like "CN=foo.bar,O=Org"
func extractCN(subjectDN string) string {
for _, part := range strings.Split(subjectDN, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "CN=") {
return strings.TrimPrefix(part, "CN=")
}
}
return ""
}
// RevokeCertificate revokes a certificate
func (s *CertificateService) RevokeCertificate(serialNumber string, req *types.CertRevoke) error {
reason := req.Reason
if reason == "" {
reason = "UNSPECIFIED"
}
return s.client.RevokeCertificate(req.IssuerDN, serialNumber, reason)
}

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
}

324
internal/service/dns.go Normal file
View File

@@ -0,0 +1,324 @@
package service
import (
"fmt"
"strings"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// DNSService handles PowerDNS zone and record operations
type DNSService struct {
client *client.PowerDNSClient
logger zerolog.Logger
}
// NewDNSService creates a new DNS service
func NewDNSService(pdnsClient *client.PowerDNSClient, logger zerolog.Logger) *DNSService {
return &DNSService{
client: pdnsClient,
logger: logger.With().Str("service", "dns").Logger(),
}
}
// ListZones lists all DNS zones
func (s *DNSService) ListZones() ([]types.DNSZone, error) {
zones, err := s.client.ListZones()
if err != nil {
return nil, err
}
result := make([]types.DNSZone, 0, len(zones))
for _, z := range zones {
result = append(result, types.DNSZone{
ID: z.ID,
Name: z.Name,
Kind: z.Kind,
DNSSec: z.DNSSec,
Serial: z.Serial,
NotifiedSerial: z.NotifiedSerial,
})
}
return result, nil
}
// GetZone gets a zone with records
func (s *DNSService) GetZone(zoneID string) (*types.DNSZone, error) {
z, err := s.client.GetZone(zoneID)
if err != nil {
return nil, err
}
zone := &types.DNSZone{
ID: z.ID,
Name: z.Name,
Kind: z.Kind,
DNSSec: z.DNSSec,
Serial: z.Serial,
NotifiedSerial: z.NotifiedSerial,
SOAEdit: z.SOAEdit,
SOAEditAPI: z.SOAEditAPI,
}
records := make([]types.DNSRecord, 0, len(z.RRSets))
for _, rr := range z.RRSets {
entries := make([]types.DNSRecordEntry, 0, len(rr.Records))
for _, r := range rr.Records {
entries = append(entries, types.DNSRecordEntry{
Content: r.Content,
Disabled: r.Disabled,
})
}
records = append(records, types.DNSRecord{
Name: rr.Name,
Type: rr.Type,
TTL: rr.TTL,
Records: entries,
})
}
zone.Records = records
return zone, nil
}
// CreateZone creates a new DNS zone
func (s *DNSService) CreateZone(req *types.DNSZoneCreate) (*types.DNSZone, error) {
kind := req.Kind
if kind == "" {
kind = "Native"
}
name := req.Name
if !strings.HasSuffix(name, ".") {
name += "."
}
z, err := s.client.CreateZone(&client.ZoneCreate{
Name: name,
Kind: kind,
Nameservers: req.Nameservers,
Masters: req.Masters,
})
if err != nil {
return nil, err
}
return &types.DNSZone{
ID: z.ID,
Name: z.Name,
Kind: z.Kind,
}, nil
}
// UpdateZone updates zone metadata
func (s *DNSService) UpdateZone(zoneID string, req *types.DNSZoneUpdate) error {
data := make(map[string]interface{})
if req.Kind != nil {
data["kind"] = *req.Kind
}
if req.Masters != nil {
data["masters"] = req.Masters
}
return s.client.UpdateZone(zoneID, data)
}
// DeleteZone deletes a zone
func (s *DNSService) DeleteZone(zoneID string) error {
return s.client.DeleteZone(zoneID)
}
// NotifyZone sends NOTIFY to slaves
func (s *DNSService) NotifyZone(zoneID string) error {
return s.client.NotifyZone(zoneID)
}
// ListRecords lists records in a zone
func (s *DNSService) ListRecords(zoneID string) ([]types.DNSRecord, error) {
zone, err := s.GetZone(zoneID)
if err != nil {
return nil, err
}
return zone.Records, nil
}
// ChangeRecords applies record changes to a zone using PATCH semantics
func (s *DNSService) ChangeRecords(zoneID string, changes []types.DNSRecordChange) error {
rrsets := make([]client.RRSet, 0, len(changes))
for _, ch := range changes {
name := ch.Name
if !strings.HasSuffix(name, ".") {
name += "."
}
records := make([]client.Record, 0, len(ch.Records))
for _, r := range ch.Records {
records = append(records, client.Record{
Content: r.Content,
Disabled: r.Disabled,
})
}
ttl := ch.TTL
if ttl == 0 {
ttl = 3600
}
rrsets = append(rrsets, client.RRSet{
Name: name,
Type: ch.Type,
TTL: ttl,
ChangeType: ch.ChangeType,
Records: records,
})
}
return s.client.PatchRRSets(zoneID, rrsets)
}
// SetupDomain creates a zone with standard mail DNS records (MX, SPF, DKIM, DMARC)
func (s *DNSService) SetupDomain(req *types.DomainSetup) (*types.DNSZone, error) {
domain := req.Domain
if !strings.HasSuffix(domain, ".") {
domain += "."
}
// Create zone first
zone, err := s.client.CreateZone(&client.ZoneCreate{
Name: domain,
Kind: "Native",
Nameservers: []string{
"ns1.gosec.cloud.",
"ns2.gosec.cloud.",
},
})
if err != nil {
return nil, fmt.Errorf("failed to create zone: %w", err)
}
// Build standard mail records
mxHost := req.MXHost
if mxHost == "" {
mxHost = "mail.gosec.cloud."
}
if !strings.HasSuffix(mxHost, ".") {
mxHost += "."
}
rrsets := []client.RRSet{
{
Name: domain,
Type: "MX",
TTL: 3600,
ChangeType: "REPLACE",
Records: []client.Record{{Content: "10 " + mxHost}},
},
}
// SPF record
spf := "v=spf1"
if len(req.SPFIncludes) > 0 {
for _, inc := range req.SPFIncludes {
spf += " include:" + inc
}
}
spf += " mx -all"
rrsets = append(rrsets, client.RRSet{
Name: domain,
Type: "TXT",
TTL: 3600,
ChangeType: "REPLACE",
Records: []client.Record{{Content: fmt.Sprintf(`"%s"`, spf)}},
})
// DKIM record
if req.DKIMKey != "" {
rrsets = append(rrsets, client.RRSet{
Name: "default._domainkey." + domain,
Type: "TXT",
TTL: 3600,
ChangeType: "REPLACE",
Records: []client.Record{{Content: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, req.DKIMKey)}},
})
}
// DMARC record
rrsets = append(rrsets, client.RRSet{
Name: "_dmarc." + domain,
Type: "TXT",
TTL: 3600,
ChangeType: "REPLACE",
Records: []client.Record{{Content: fmt.Sprintf(`"v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, strings.TrimSuffix(domain, "."))}},
})
if err := s.client.PatchRRSets(zone.ID, rrsets); err != nil {
return nil, fmt.Errorf("zone created but record setup failed: %w", err)
}
result := &types.DNSZone{
ID: zone.ID,
Name: zone.Name,
Kind: zone.Kind,
}
return result, nil
}
// VerifyDomain checks DNS propagation for a domain
func (s *DNSService) VerifyDomain(domain string) (*types.DomainVerifyResult, error) {
if !strings.HasSuffix(domain, ".") {
domain += "."
}
zone, err := s.client.GetZone(domain)
if err != nil {
return nil, fmt.Errorf("zone not found: %w", err)
}
results := make(map[string]string)
hasMX, hasSPF, hasDMARC := false, false, false
for _, rr := range zone.RRSets {
switch {
case rr.Type == "MX" && rr.Name == domain:
hasMX = true
results["MX"] = "OK"
case rr.Type == "TXT" && rr.Name == domain:
for _, r := range rr.Records {
if strings.Contains(r.Content, "v=spf1") {
hasSPF = true
results["SPF"] = "OK"
}
}
case rr.Type == "TXT" && rr.Name == "_dmarc."+domain:
hasDMARC = true
results["DMARC"] = "OK"
case rr.Type == "TXT" && strings.HasSuffix(rr.Name, "._domainkey."+domain):
results["DKIM"] = "OK"
}
}
if !hasMX {
results["MX"] = "MISSING"
}
if !hasSPF {
results["SPF"] = "MISSING"
}
if !hasDMARC {
results["DMARC"] = "MISSING"
}
allOK := true
for _, v := range results {
if v != "OK" {
allOK = false
break
}
}
return &types.DomainVerifyResult{
Domain: strings.TrimSuffix(domain, "."),
Results: results,
AllOK: allOK,
}, nil
}

648
internal/service/ldap.go Normal file
View File

@@ -0,0 +1,648 @@
package service
import (
"fmt"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/internal/schema"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// LDAPService handles FreeIPA user and group operations
type LDAPService struct {
client *client.LDAPClient
baseDN string
logger zerolog.Logger
registry *schema.Registry
}
// NewLDAPService creates a new LDAP service
func NewLDAPService(ldapClient *client.LDAPClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService {
return &LDAPService{
client: ldapClient,
baseDN: baseDN,
logger: logger.With().Str("service", "ldap").Logger(),
registry: registry,
}
}
func (s *LDAPService) userBaseDN() string {
return "cn=users,cn=accounts," + s.baseDN
}
func (s *LDAPService) groupBaseDN() string {
return "cn=groups,cn=accounts," + s.baseDN
}
func (s *LDAPService) userDN(uid string) string {
return fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(uid), s.userBaseDN())
}
func (s *LDAPService) groupDN(cn string) string {
return fmt.Sprintf("cn=%s,%s", ldap.EscapeFilter(cn), s.groupBaseDN())
}
// coreUserAttrs are the base LDAP attributes for user listing (no gsc* attrs)
var coreUserAttrs = []string{
"uid", "givenName", "sn", "displayName", "mail", "telephoneNumber",
"title", "nsAccountLock", "loginShell", "homeDirectory", "memberOf",
}
// userSearchAttrs returns core attrs plus all gsc* attrs for full user retrieval
func (s *LDAPService) userSearchAttrs() []string {
gscAttrs := s.registry.AllUserAttrs()
attrs := make([]string, 0, len(coreUserAttrs)+len(gscAttrs)+1)
attrs = append(attrs, coreUserAttrs...)
attrs = append(attrs, "objectClass")
attrs = append(attrs, gscAttrs...)
return attrs
}
var groupAttrs = []string{
"cn", "description", "member", "gidNumber",
}
// ListUsers searches for users, optionally filtering by search string,
// service objectClasses, and/or arbitrary LDAP attribute values.
//
// attrFilters maps raw LDAP attribute names to match values. Values may
// contain LDAP wildcards (e.g. "*@example.com"). The attribute name itself
// is sanitised to prevent filter injection.
func (s *LDAPService) ListUsers(search string, limit int, serviceFilters []string, attrFilters map[string]string) ([]types.LDAPUser, error) {
// Start with base object class filter
parts := []string{"(objectClass=posixAccount)"}
// Free-text search across core fields
if search != "" {
escaped := ldap.EscapeFilter(search)
parts = append(parts, fmt.Sprintf("(|(uid=*%s*)(givenName=*%s*)(sn=*%s*)(mail=*%s*))",
escaped, escaped, escaped, escaped))
}
// Service objectClass filters
for _, svc := range serviceFilters {
oc := s.registry.UserOCForDomain(svc)
if oc != "" {
parts = append(parts, fmt.Sprintf("(objectClass=%s)", oc))
}
}
// Dynamic LDAP attribute filters
// Collect extra attrs we need to request so the server evaluates the filter
var extraAttrs []string
for attr, val := range attrFilters {
// Sanitise attribute name: only allow alphanumeric, dash, semicolon
safe := true
for _, ch := range attr {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == ';') {
safe = false
break
}
}
if !safe || attr == "" {
continue
}
// Escape value but preserve * wildcards for substring matching.
// Split on *, escape each segment, rejoin with *.
segments := strings.Split(val, "*")
for i, seg := range segments {
segments[i] = ldap.EscapeFilter(seg)
}
escapedVal := strings.Join(segments, "*")
parts = append(parts, fmt.Sprintf("(%s=%s)", attr, escapedVal))
extraAttrs = append(extraAttrs, attr)
}
// Build final filter
var filter string
if len(parts) == 1 {
filter = parts[0]
} else {
filter = "(&" + strings.Join(parts, "") + ")"
}
// When service filters are present, fetch full gsc* attrs so the
// response includes the services block (e.g. gscSID for chat).
includeServices := len(serviceFilters) > 0
var attrs []string
if includeServices {
attrs = s.userSearchAttrs()
if len(extraAttrs) > 0 {
attrs = append(attrs, extraAttrs...)
}
} else {
attrs = coreUserAttrs
if len(extraAttrs) > 0 {
attrs = make([]string, len(coreUserAttrs), len(coreUserAttrs)+len(extraAttrs))
copy(attrs, coreUserAttrs)
attrs = append(attrs, extraAttrs...)
}
}
entries, err := s.client.Search(s.userBaseDN(), filter, attrs, limit)
if err != nil {
return nil, err
}
users := make([]types.LDAPUser, 0, len(entries))
for _, entry := range entries {
users = append(users, s.entryToUser(entry, includeServices))
}
return users, nil
}
// GetUser gets a user by UID with full service attributes
func (s *LDAPService) GetUser(uid string) (*types.LDAPUser, error) {
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
entry, err := s.client.SearchOne(s.userBaseDN(), filter, s.userSearchAttrs())
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
user := s.entryToUser(entry, true)
return &user, nil
}
// GetUserServices returns only service attributes for a user
func (s *LDAPService) GetUserServices(uid string, domain string) (map[string]map[string]interface{}, error) {
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
entry, err := s.client.SearchOne(s.userBaseDN(), filter, s.userSearchAttrs())
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
services := s.extractServices(entry)
if domain != "" {
filtered := make(map[string]map[string]interface{})
if svc, ok := services[domain]; ok {
filtered[domain] = svc
}
return filtered, nil
}
return services, nil
}
// CreateUser creates a new FreeIPA user
func (s *LDAPService) CreateUser(req *types.LDAPUserCreate) (*types.LDAPUser, error) {
dn := s.userDN(req.UID)
objectClasses := []string{"top", "person", "organizationalPerson", "inetOrgPerson", "posixAccount", "krbPrincipalAux", "ipaObject"}
addReq := ldap.NewAddRequest(dn, nil)
addReq.Attribute("uid", []string{req.UID})
addReq.Attribute("givenName", []string{req.FirstName})
addReq.Attribute("sn", []string{req.LastName})
addReq.Attribute("cn", []string{req.FirstName + " " + req.LastName})
addReq.Attribute("displayName", []string{req.FirstName + " " + req.LastName})
if req.Email != "" {
addReq.Attribute("mail", []string{req.Email})
}
if req.Phone != "" {
addReq.Attribute("telephoneNumber", []string{req.Phone})
}
if req.Title != "" {
addReq.Attribute("title", []string{req.Title})
}
shell := "/bin/bash"
if req.Shell != "" {
shell = req.Shell
}
addReq.Attribute("loginShell", []string{shell})
addReq.Attribute("homeDirectory", []string{"/home/" + req.UID})
// Process service attributes
if len(req.Services) > 0 {
svcOCs, svcAttrs, err := s.resolveServices(req.Services)
if err != nil {
return nil, fmt.Errorf("invalid services: %w", err)
}
objectClasses = append(objectClasses, svcOCs...)
for attrName, vals := range svcAttrs {
addReq.Attribute(attrName, vals)
}
// Add audit timestamps
now := time.Now().UTC().Format("20060102150405Z")
addReq.Attribute("gscCreatedAt", []string{now})
addReq.Attribute("gscModifiedAt", []string{now})
}
addReq.Attribute("objectClass", objectClasses)
if err := s.client.Add(addReq); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Set password if provided
if req.Password != "" {
if err := s.client.PasswordModify(dn, req.Password); err != nil {
s.logger.Warn().Err(err).Str("uid", req.UID).Msg("user created but password set failed")
}
}
return s.GetUser(req.UID)
}
// UpdateUser updates a user's attributes
func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) {
dn := s.userDN(uid)
modReq := ldap.NewModifyRequest(dn, nil)
modified := false
if req.FirstName != nil {
modReq.Replace("givenName", []string{*req.FirstName})
modified = true
}
if req.LastName != nil {
modReq.Replace("sn", []string{*req.LastName})
modified = true
}
if req.FirstName != nil || req.LastName != nil {
// Update display name and cn
first, last := "", ""
if req.FirstName != nil {
first = *req.FirstName
}
if req.LastName != nil {
last = *req.LastName
}
if first != "" || last != "" {
display := strings.TrimSpace(first + " " + last)
if display != "" {
modReq.Replace("displayName", []string{display})
modReq.Replace("cn", []string{display})
}
}
}
if req.Email != nil {
modReq.Replace("mail", []string{*req.Email})
modified = true
}
if req.Phone != nil {
modReq.Replace("telephoneNumber", []string{*req.Phone})
modified = true
}
if req.Title != nil {
modReq.Replace("title", []string{*req.Title})
modified = true
}
if req.Shell != nil {
modReq.Replace("loginShell", []string{*req.Shell})
modified = true
}
if req.Disabled != nil {
if *req.Disabled {
modReq.Replace("nsAccountLock", []string{"TRUE"})
} else {
modReq.Replace("nsAccountLock", []string{"FALSE"})
}
modified = true
}
// Process service attributes
if len(req.Services) > 0 {
svcOCs, svcAttrs, err := s.resolveServices(req.Services)
if err != nil {
return nil, fmt.Errorf("invalid services: %w", err)
}
// Fetch current objectClasses to determine which to add
currentOCs, err := s.getCurrentObjectClasses(uid)
if err != nil {
return nil, fmt.Errorf("failed to read current objectClasses: %w", err)
}
currentOCSet := make(map[string]bool, len(currentOCs))
for _, oc := range currentOCs {
currentOCSet[oc] = true
}
newOCs := make([]string, 0)
for _, oc := range svcOCs {
if !currentOCSet[oc] {
newOCs = append(newOCs, oc)
}
}
if len(newOCs) > 0 {
modReq.Add("objectClass", newOCs)
}
for attrName, vals := range svcAttrs {
modReq.Replace(attrName, vals)
}
// Update audit timestamp
now := time.Now().UTC().Format("20060102150405Z")
modReq.Replace("gscModifiedAt", []string{now})
modified = true
}
if !modified {
return s.GetUser(uid)
}
if err := s.client.Modify(modReq); err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
return s.GetUser(uid)
}
// DisableUser disables a user account
func (s *LDAPService) DisableUser(uid string) error {
dn := s.userDN(uid)
modReq := ldap.NewModifyRequest(dn, nil)
modReq.Replace("nsAccountLock", []string{"TRUE"})
return s.client.Modify(modReq)
}
// ResetPassword resets a user's password
func (s *LDAPService) ResetPassword(uid, newPassword string) error {
dn := s.userDN(uid)
return s.client.PasswordModify(dn, newPassword)
}
// GetUserGroups lists groups a user belongs to
func (s *LDAPService) GetUserGroups(uid string) ([]string, error) {
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
entry, err := s.client.SearchOne(s.userBaseDN(), filter, []string{"memberOf"})
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
memberOf := entry.GetAttributeValues("memberOf")
groups := make([]string, 0, len(memberOf))
for _, dn := range memberOf {
// Extract cn from DN
parts := strings.Split(dn, ",")
if len(parts) > 0 && strings.HasPrefix(parts[0], "cn=") {
groups = append(groups, strings.TrimPrefix(parts[0], "cn="))
}
}
return groups, nil
}
// ListGroups searches for groups
func (s *LDAPService) ListGroups(search string, limit int) ([]types.LDAPGroup, error) {
filter := "(objectClass=groupOfNames)"
if search != "" {
escaped := ldap.EscapeFilter(search)
filter = fmt.Sprintf("(&(objectClass=groupOfNames)(|(cn=*%s*)(description=*%s*)))", escaped, escaped)
}
entries, err := s.client.Search(s.groupBaseDN(), filter, groupAttrs, limit)
if err != nil {
return nil, err
}
groups := make([]types.LDAPGroup, 0, len(entries))
for _, entry := range entries {
groups = append(groups, s.entryToGroup(entry))
}
return groups, nil
}
// GetGroup gets a group by CN
func (s *LDAPService) GetGroup(cn string) (*types.LDAPGroup, error) {
filter := fmt.Sprintf("(&(objectClass=groupOfNames)(cn=%s))", ldap.EscapeFilter(cn))
entry, err := s.client.SearchOne(s.groupBaseDN(), filter, groupAttrs)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
group := s.entryToGroup(entry)
return &group, nil
}
// CreateGroup creates a new group
func (s *LDAPService) CreateGroup(req *types.LDAPGroupCreate) (*types.LDAPGroup, error) {
dn := s.groupDN(req.CN)
addReq := ldap.NewAddRequest(dn, nil)
addReq.Attribute("objectClass", []string{"top", "groupOfNames", "posixGroup", "ipaObject"})
addReq.Attribute("cn", []string{req.CN})
if req.Description != "" {
addReq.Attribute("description", []string{req.Description})
}
if err := s.client.Add(addReq); err != nil {
return nil, fmt.Errorf("failed to create group: %w", err)
}
return s.GetGroup(req.CN)
}
// UpdateGroup updates a group's attributes
func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) {
dn := s.groupDN(cn)
modReq := ldap.NewModifyRequest(dn, nil)
if req.Description != nil {
modReq.Replace("description", []string{*req.Description})
}
if err := s.client.Modify(modReq); err != nil {
return nil, fmt.Errorf("failed to update group: %w", err)
}
return s.GetGroup(cn)
}
// DeleteGroup deletes a group
func (s *LDAPService) DeleteGroup(cn string) error {
dn := s.groupDN(cn)
return s.client.Delete(dn)
}
// GetGroupMembers lists members of a group
func (s *LDAPService) GetGroupMembers(cn string) ([]string, error) {
group, err := s.GetGroup(cn)
if err != nil {
return nil, err
}
if group == nil {
return nil, nil
}
return group.Members, nil
}
// AddGroupMembers adds members to a group
func (s *LDAPService) AddGroupMembers(cn string, uids []string) error {
dn := s.groupDN(cn)
modReq := ldap.NewModifyRequest(dn, nil)
for _, uid := range uids {
memberDN := s.userDN(uid)
modReq.Add("member", []string{memberDN})
}
return s.client.Modify(modReq)
}
// RemoveGroupMember removes a member from a group
func (s *LDAPService) RemoveGroupMember(cn, uid string) error {
dn := s.groupDN(cn)
memberDN := s.userDN(uid)
modReq := ldap.NewModifyRequest(dn, nil)
modReq.Delete("member", []string{memberDN})
return s.client.Modify(modReq)
}
func (s *LDAPService) entryToUser(entry *ldap.Entry, includeServices bool) types.LDAPUser {
memberOf := entry.GetAttributeValues("memberOf")
groups := make([]string, 0, len(memberOf))
for _, dn := range memberOf {
parts := strings.Split(dn, ",")
if len(parts) > 0 && strings.HasPrefix(parts[0], "cn=") {
groups = append(groups, strings.TrimPrefix(parts[0], "cn="))
}
}
disabled := strings.EqualFold(entry.GetAttributeValue("nsAccountLock"), "TRUE")
user := types.LDAPUser{
UID: entry.GetAttributeValue("uid"),
FirstName: entry.GetAttributeValue("givenName"),
LastName: entry.GetAttributeValue("sn"),
DisplayName: entry.GetAttributeValue("displayName"),
Email: entry.GetAttributeValue("mail"),
Phone: entry.GetAttributeValue("telephoneNumber"),
Title: entry.GetAttributeValue("title"),
Disabled: disabled,
Groups: groups,
Shell: entry.GetAttributeValue("loginShell"),
HomeDir: entry.GetAttributeValue("homeDirectory"),
}
if includeServices {
user.ObjectClasses = entry.GetAttributeValues("objectClass")
user.Services = s.extractServices(entry)
}
return user
}
// extractServices extracts gsc* attributes from an LDAP entry, grouped by domain
func (s *LDAPService) extractServices(entry *ldap.Entry) map[string]map[string]interface{} {
services := make(map[string]map[string]interface{})
for _, attr := range entry.Attributes {
def := s.registry.GetAttr(attr.Name)
if def == nil {
continue
}
val := s.registry.LDAPValueToGo(def, attr.Values)
if val == nil {
continue
}
if services[def.Domain] == nil {
services[def.Domain] = make(map[string]interface{})
}
services[def.Domain][def.JSONName] = val
}
return services
}
// resolveServices validates and converts service attributes to LDAP format.
// Returns: required objectClasses, LDAP attribute map, or error.
func (s *LDAPService) resolveServices(services map[string]map[string]interface{}) ([]string, map[string][]string, error) {
ldapAttrs := make(map[string][]string)
usedLDAPNames := make([]string, 0)
for domain, attrs := range services {
domainDefs := s.registry.AttrsForDomain(domain)
if domainDefs == nil {
return nil, nil, fmt.Errorf("unknown service domain: %s", domain)
}
for jsonName, value := range attrs {
def := s.registry.GetAttrByJSON(domain, jsonName)
if def == nil {
return nil, nil, fmt.Errorf("unknown attribute %s in domain %s", jsonName, domain)
}
if def.ReadOnly {
continue // skip read-only attrs silently
}
vals, err := s.registry.GoValueToLDAP(def, value)
if err != nil {
return nil, nil, fmt.Errorf("attribute %s.%s: %w", domain, jsonName, err)
}
if vals != nil {
ldapAttrs[def.LDAPName] = vals
usedLDAPNames = append(usedLDAPNames, def.LDAPName)
}
}
}
// Determine required objectClasses
ocs := s.registry.RequiredOCsForAttrs(usedLDAPNames)
// Validate that all MUST attrs for each OC are provided
for _, ocName := range ocs {
oc := s.registry.GetObjectClass(ocName)
if oc == nil {
continue
}
for _, must := range oc.Must {
if _, ok := ldapAttrs[must]; !ok {
return nil, nil, fmt.Errorf("objectClass %s requires attribute %s", ocName, must)
}
}
}
return ocs, ldapAttrs, nil
}
// getCurrentObjectClasses fetches the current objectClasses of a user entry
func (s *LDAPService) getCurrentObjectClasses(uid string) ([]string, error) {
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
entry, err := s.client.SearchOne(s.userBaseDN(), filter, []string{"objectClass"})
if err != nil {
return nil, err
}
if entry == nil {
return nil, fmt.Errorf("user not found: %s", uid)
}
return entry.GetAttributeValues("objectClass"), nil
}
func (s *LDAPService) entryToGroup(entry *ldap.Entry) types.LDAPGroup {
members := entry.GetAttributeValues("member")
uids := make([]string, 0, len(members))
for _, dn := range members {
parts := strings.Split(dn, ",")
if len(parts) > 0 && strings.HasPrefix(parts[0], "uid=") {
uids = append(uids, strings.TrimPrefix(parts[0], "uid="))
}
}
return types.LDAPGroup{
CN: entry.GetAttributeValue("cn"),
Description: entry.GetAttributeValue("description"),
Members: uids,
GIDNumber: entry.GetAttributeValue("gidNumber"),
}
}

View File

@@ -0,0 +1,321 @@
package service
import (
"fmt"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/internal/schema"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// LDAPEntityService handles generic LDAP entity CRUD operations
type LDAPEntityService struct {
client *client.LDAPClient
baseDN string
registry *schema.Registry
logger zerolog.Logger
}
// NewLDAPEntityService creates a new entity service
func NewLDAPEntityService(ldapClient *client.LDAPClient, baseDN string, registry *schema.Registry, logger zerolog.Logger) *LDAPEntityService {
return &LDAPEntityService{
client: ldapClient,
baseDN: baseDN,
registry: registry,
logger: logger.With().Str("service", "ldap-entities").Logger(),
}
}
// entityBaseDN returns the full base DN for an entity type
func (s *LDAPEntityService) entityBaseDN(et *schema.EntityTypeDef) string {
return et.BaseDN + "," + s.baseDN
}
// entityDN returns the full DN for a specific entity
func (s *LDAPEntityService) entityDN(et *schema.EntityTypeDef, rdnValue string) string {
return fmt.Sprintf("%s=%s,%s", et.RDNAttribute, ldap.EscapeFilter(rdnValue), s.entityBaseDN(et))
}
// entityAttrs returns all searchable LDAP attribute names for an entity type
func (s *LDAPEntityService) entityAttrs(et *schema.EntityTypeDef) []string {
attrs := []string{"objectClass"}
for _, ocName := range et.ObjectClasses {
oc := s.registry.GetObjectClass(ocName)
if oc == nil {
continue
}
attrs = append(attrs, oc.Must...)
attrs = append(attrs, oc.May...)
}
// Deduplicate
seen := make(map[string]bool, len(attrs))
unique := make([]string, 0, len(attrs))
for _, a := range attrs {
if !seen[a] {
seen[a] = true
unique = append(unique, a)
}
}
return unique
}
// ListEntities searches for entities of a given type
func (s *LDAPEntityService) ListEntities(typeName, search string, limit int) ([]types.LDAPEntity, error) {
et := s.registry.GetEntityType(typeName)
if et == nil {
return nil, fmt.Errorf("unknown entity type: %s", typeName)
}
filter := et.SearchFilter
if search != "" {
escaped := ldap.EscapeFilter(search)
// Search by RDN attribute or description
filter = fmt.Sprintf("(&%s(|(%s=*%s*)(gscDescription=*%s*)))",
et.SearchFilter, et.RDNAttribute, escaped, escaped)
}
attrs := s.entityAttrs(et)
entries, err := s.client.Search(s.entityBaseDN(et), filter, attrs, limit)
if err != nil {
return nil, fmt.Errorf("entity search failed: %w", err)
}
entities := make([]types.LDAPEntity, 0, len(entries))
for _, entry := range entries {
entities = append(entities, s.entryToEntity(entry, et))
}
return entities, nil
}
// GetEntity retrieves a single entity by its RDN value
func (s *LDAPEntityService) GetEntity(typeName, rdnValue string) (*types.LDAPEntity, error) {
et := s.registry.GetEntityType(typeName)
if et == nil {
return nil, fmt.Errorf("unknown entity type: %s", typeName)
}
filter := fmt.Sprintf("(&%s(%s=%s))",
et.SearchFilter, et.RDNAttribute, ldap.EscapeFilter(rdnValue))
attrs := s.entityAttrs(et)
entry, err := s.client.SearchOne(s.entityBaseDN(et), filter, attrs)
if err != nil {
return nil, fmt.Errorf("entity lookup failed: %w", err)
}
if entry == nil {
return nil, nil
}
entity := s.entryToEntity(entry, et)
return &entity, nil
}
// CreateEntity creates a new entity
func (s *LDAPEntityService) CreateEntity(typeName string, req *types.LDAPEntityCreate) (*types.LDAPEntity, error) {
et := s.registry.GetEntityType(typeName)
if et == nil {
return nil, fmt.Errorf("unknown entity type: %s", typeName)
}
// Resolve attributes from JSON names to LDAP names
ldapAttrs, err := s.resolveEntityAttrs(et, req.Attributes)
if err != nil {
return nil, err
}
// Validate required attributes
for _, reqAttr := range et.RequiredAttrs {
if _, ok := ldapAttrs[reqAttr]; !ok {
// Try to find JSON name for better error message
def := s.registry.GetAttr(reqAttr)
jsonName := reqAttr
if def != nil {
jsonName = def.JSONName
}
return nil, fmt.Errorf("required attribute missing: %s", jsonName)
}
}
// Determine RDN value
rdnVals, ok := ldapAttrs[et.RDNAttribute]
if !ok || len(rdnVals) == 0 {
return nil, fmt.Errorf("RDN attribute %s is required", et.RDNAttribute)
}
rdnValue := rdnVals[0]
// Build DN
dn := s.entityDN(et, rdnValue)
// Add audit timestamps
now := time.Now().UTC().Format("20060102150405Z")
ldapAttrs["gscCreatedAt"] = []string{now}
ldapAttrs["gscModifiedAt"] = []string{now}
// Create LDAP entry
addReq := ldap.NewAddRequest(dn, nil)
addReq.Attribute("objectClass", et.ObjectClasses)
for attrName, vals := range ldapAttrs {
addReq.Attribute(attrName, vals)
}
if err := s.client.Add(addReq); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
return nil, fmt.Errorf("CONFLICT: entity already exists: %s=%s", et.RDNAttribute, rdnValue)
}
return nil, fmt.Errorf("failed to create entity: %w", err)
}
return s.GetEntity(typeName, rdnValue)
}
// UpdateEntity modifies an existing entity
func (s *LDAPEntityService) UpdateEntity(typeName, rdnValue string, req *types.LDAPEntityUpdate) (*types.LDAPEntity, error) {
et := s.registry.GetEntityType(typeName)
if et == nil {
return nil, fmt.Errorf("unknown entity type: %s", typeName)
}
// Resolve attributes
ldapAttrs, err := s.resolveEntityAttrs(et, req.Attributes)
if err != nil {
return nil, err
}
if len(ldapAttrs) == 0 {
return s.GetEntity(typeName, rdnValue)
}
dn := s.entityDN(et, rdnValue)
modReq := ldap.NewModifyRequest(dn, nil)
for attrName, vals := range ldapAttrs {
modReq.Replace(attrName, vals)
}
// Update audit timestamp
now := time.Now().UTC().Format("20060102150405Z")
modReq.Replace("gscModifiedAt", []string{now})
if err := s.client.Modify(modReq); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return nil, fmt.Errorf("NOT_FOUND: entity not found: %s=%s", et.RDNAttribute, rdnValue)
}
return nil, fmt.Errorf("failed to update entity: %w", err)
}
return s.GetEntity(typeName, rdnValue)
}
// DeleteEntity removes an entity
func (s *LDAPEntityService) DeleteEntity(typeName, rdnValue string) error {
et := s.registry.GetEntityType(typeName)
if et == nil {
return fmt.Errorf("unknown entity type: %s", typeName)
}
dn := s.entityDN(et, rdnValue)
if err := s.client.Delete(dn); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return fmt.Errorf("NOT_FOUND: entity not found: %s=%s", et.RDNAttribute, rdnValue)
}
return fmt.Errorf("failed to delete entity: %w", err)
}
return nil
}
// resolveEntityAttrs converts JSON attribute names to LDAP attribute names with type conversion
func (s *LDAPEntityService) resolveEntityAttrs(et *schema.EntityTypeDef, attrs map[string]interface{}) (map[string][]string, error) {
ldapAttrs := make(map[string][]string)
for jsonName, value := range attrs {
// Try to find by JSON name in the entity's domain
def := s.registry.GetAttrByJSON(et.Domain, jsonName)
if def == nil {
// Also try common domain
def = s.registry.GetAttrByJSON("common", jsonName)
}
if def == nil {
// Try as direct LDAP name
def = s.registry.GetAttr(jsonName)
}
if def == nil {
return nil, fmt.Errorf("unknown attribute: %s", jsonName)
}
if def.ReadOnly {
continue
}
vals, err := s.registry.GoValueToLDAP(def, value)
if err != nil {
return nil, fmt.Errorf("attribute %s: %w", jsonName, err)
}
if vals != nil {
ldapAttrs[def.LDAPName] = vals
}
}
return ldapAttrs, nil
}
// entryToEntity converts an LDAP entry to a generic entity response
func (s *LDAPEntityService) entryToEntity(entry *ldap.Entry, et *schema.EntityTypeDef) types.LDAPEntity {
attrs := make(map[string]interface{})
for _, ldapAttr := range entry.Attributes {
if ldapAttr.Name == "objectClass" {
continue
}
def := s.registry.GetAttr(ldapAttr.Name)
if def == nil {
// Include unregistered attrs as raw strings
if len(ldapAttr.Values) == 1 {
attrs[ldapAttr.Name] = ldapAttr.Values[0]
} else if len(ldapAttr.Values) > 1 {
attrs[ldapAttr.Name] = ldapAttr.Values
}
continue
}
val := s.registry.LDAPValueToGo(def, ldapAttr.Values)
if val != nil {
attrs[def.JSONName] = val
}
}
// Extract RDN value
rdnValue := ""
if et.RDNAttribute == "cn" {
rdnValue = entry.GetAttributeValue("cn")
} else {
rdnValue = entry.GetAttributeValue(et.RDNAttribute)
}
return types.LDAPEntity{
DN: entry.DN,
Type: et.Name,
RDN: rdnValue,
ObjectClasses: entry.GetAttributeValues("objectClass"),
Attributes: attrs,
}
}
// ClassifyError classifies LDAP errors for HTTP status mapping
func ClassifyError(err error) (string, string) {
msg := err.Error()
if strings.HasPrefix(msg, "CONFLICT:") {
return "conflict", strings.TrimPrefix(msg, "CONFLICT: ")
}
if strings.HasPrefix(msg, "NOT_FOUND:") {
return "not_found", strings.TrimPrefix(msg, "NOT_FOUND: ")
}
if strings.Contains(msg, "unknown entity type") || strings.Contains(msg, "unknown attribute") || strings.Contains(msg, "required attribute") {
return "validation", msg
}
return "internal", msg
}

1091
internal/service/pbx.go Normal file

File diff suppressed because it is too large Load Diff

514
internal/service/persona.go Normal file
View File

@@ -0,0 +1,514 @@
package service
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PersonaService handles persona operations against gsc_persona database
type PersonaService struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewPersonaService creates a new persona service
func NewPersonaService(pool *pgxpool.Pool, logger zerolog.Logger) *PersonaService {
return &PersonaService{
pool: pool,
logger: logger.With().Str("service", "persona").Logger(),
}
}
// ListPersonas lists personas for a tenant with optional status filter
func (s *PersonaService) ListPersonas(ctx context.Context, tenantID uuid.UUID, params types.ListParams) ([]types.PersonaSummary, int64, error) {
params = types.DefaultListParams(params)
countQuery := `SELECT COUNT(*) FROM persona.personas WHERE tenant_id = $1`
listQuery := `SELECT id, tenant_id, name, archetype, status, is_default, created_at, updated_at
FROM persona.personas WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if params.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
listQuery += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, params.Status)
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()
personas := make([]types.PersonaSummary, 0)
for rows.Next() {
var p types.PersonaSummary
if err := rows.Scan(&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.Status, &p.IsDefault, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, 0, fmt.Errorf("scan failed: %w", err)
}
personas = append(personas, p)
}
return personas, total, nil
}
// GetPersona gets a full persona configuration by ID and tenant
func (s *PersonaService) GetPersona(ctx context.Context, id, tenantID uuid.UUID) (*types.PersonaConfig, error) {
var p types.PersonaConfig
var positiveRules, negativeRules, guardrailsConfig []byte
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, archetype, voice_tone, mbti,
openness, conscientiousness, extraversion, agreeableness, neuroticism,
positive_rules, negative_rules, backstory, world_building,
guardrails_config, topical_rails, status,
default_model, temperature, max_tokens_per_turn,
moral_care, moral_fairness, moral_rights,
moral_loyalty, moral_authority, moral_sanctity,
is_default, created_at, updated_at
FROM persona.personas
WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(
&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI,
&p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism,
&positiveRules, &negativeRules, &p.Backstory, &p.WorldBuilding,
&guardrailsConfig, &p.TopicalRails, &p.Status,
&p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn,
&p.MoralCare, &p.MoralFairness, &p.MoralRights,
&p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity,
&p.IsDefault, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("not found: %w", err)
}
p.PositiveRules = json.RawMessage(positiveRules)
p.NegativeRules = json.RawMessage(negativeRules)
p.GuardrailsConfig = json.RawMessage(guardrailsConfig)
return &p, nil
}
// CreatePersona creates a new persona
func (s *PersonaService) CreatePersona(ctx context.Context, req *types.PersonaCreate) (*types.PersonaConfig, error) {
positiveRules := req.PositiveRules
if len(positiveRules) == 0 {
positiveRules = json.RawMessage(`[]`)
}
negativeRules := req.NegativeRules
if len(negativeRules) == 0 {
negativeRules = json.RawMessage(`[]`)
}
guardrailsConfig := req.GuardrailsConfig
if len(guardrailsConfig) == 0 {
guardrailsConfig = json.RawMessage(`{}`)
}
var p types.PersonaConfig
var prOut, nrOut, gcOut []byte
err := s.pool.QueryRow(ctx, `
INSERT INTO persona.personas (
tenant_id, name, archetype, voice_tone, mbti,
openness, conscientiousness, extraversion, agreeableness, neuroticism,
positive_rules, negative_rules, backstory, world_building,
guardrails_config, topical_rails,
default_model, temperature, max_tokens_per_turn,
moral_care, moral_fairness, moral_rights,
moral_loyalty, moral_authority, moral_sanctity
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
RETURNING id, tenant_id, name, archetype, voice_tone, mbti,
openness, conscientiousness, extraversion, agreeableness, neuroticism,
positive_rules, negative_rules, backstory, world_building,
guardrails_config, topical_rails, status,
default_model, temperature, max_tokens_per_turn,
moral_care, moral_fairness, moral_rights,
moral_loyalty, moral_authority, moral_sanctity,
is_default, created_at, updated_at`,
req.TenantID, req.Name, req.Archetype, req.VoiceTone, req.MBTI,
req.Openness, req.Conscientiousness, req.Extraversion, req.Agreeableness, req.Neuroticism,
positiveRules, negativeRules, req.Backstory, req.WorldBuilding,
guardrailsConfig, req.TopicalRails,
req.DefaultModel, req.Temperature, req.MaxTokensPerTurn,
req.MoralCare, req.MoralFairness, req.MoralRights,
req.MoralLoyalty, req.MoralAuthority, req.MoralSanctity,
).Scan(
&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI,
&p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism,
&prOut, &nrOut, &p.Backstory, &p.WorldBuilding,
&gcOut, &p.TopicalRails, &p.Status,
&p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn,
&p.MoralCare, &p.MoralFairness, &p.MoralRights,
&p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity,
&p.IsDefault, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
p.PositiveRules = json.RawMessage(prOut)
p.NegativeRules = json.RawMessage(nrOut)
p.GuardrailsConfig = json.RawMessage(gcOut)
s.logger.Info().Str("id", p.ID.String()).Str("name", p.Name).Msg("Created persona")
return &p, nil
}
// UpdatePersona updates an existing persona
func (s *PersonaService) UpdatePersona(ctx context.Context, id, tenantID uuid.UUID, req *types.PersonaUpdate) (*types.PersonaConfig, error) {
setClauses := []string{}
args := []interface{}{}
argIdx := 1
addField := func(clause string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx))
args = append(args, val)
argIdx++
}
if req.Name != nil {
addField("name", *req.Name)
}
if req.Archetype != nil {
addField("archetype", *req.Archetype)
}
if req.VoiceTone != nil {
addField("voice_tone", *req.VoiceTone)
}
if req.MBTI != nil {
addField("mbti", *req.MBTI)
}
if req.Openness != nil {
addField("openness", *req.Openness)
}
if req.Conscientiousness != nil {
addField("conscientiousness", *req.Conscientiousness)
}
if req.Extraversion != nil {
addField("extraversion", *req.Extraversion)
}
if req.Agreeableness != nil {
addField("agreeableness", *req.Agreeableness)
}
if req.Neuroticism != nil {
addField("neuroticism", *req.Neuroticism)
}
if len(req.PositiveRules) > 0 {
addField("positive_rules", req.PositiveRules)
}
if len(req.NegativeRules) > 0 {
addField("negative_rules", req.NegativeRules)
}
if req.Backstory != nil {
addField("backstory", *req.Backstory)
}
if req.WorldBuilding != nil {
addField("world_building", *req.WorldBuilding)
}
if len(req.GuardrailsConfig) > 0 {
addField("guardrails_config", req.GuardrailsConfig)
}
if req.TopicalRails != nil {
addField("topical_rails", *req.TopicalRails)
}
if req.Status != nil {
addField("status", *req.Status)
}
if req.DefaultModel != nil {
addField("default_model", *req.DefaultModel)
}
if req.Temperature != nil {
addField("temperature", *req.Temperature)
}
if req.MaxTokensPerTurn != nil {
addField("max_tokens_per_turn", *req.MaxTokensPerTurn)
}
if req.MoralCare != nil {
addField("moral_care", *req.MoralCare)
}
if req.MoralFairness != nil {
addField("moral_fairness", *req.MoralFairness)
}
if req.MoralRights != nil {
addField("moral_rights", *req.MoralRights)
}
if req.MoralLoyalty != nil {
addField("moral_loyalty", *req.MoralLoyalty)
}
if req.MoralAuthority != nil {
addField("moral_authority", *req.MoralAuthority)
}
if req.MoralSanctity != nil {
addField("moral_sanctity", *req.MoralSanctity)
}
if req.IsDefault != nil {
addField("is_default", *req.IsDefault)
}
if len(setClauses) == 0 {
return s.GetPersona(ctx, id, tenantID)
}
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE persona.personas SET %s WHERE id = $%d AND tenant_id = $%d",
joinClauses(setClauses), argIdx, argIdx+1)
args = append(args, id, tenantID)
query += ` RETURNING id, tenant_id, name, archetype, voice_tone, mbti,
openness, conscientiousness, extraversion, agreeableness, neuroticism,
positive_rules, negative_rules, backstory, world_building,
guardrails_config, topical_rails, status,
default_model, temperature, max_tokens_per_turn,
moral_care, moral_fairness, moral_rights,
moral_loyalty, moral_authority, moral_sanctity,
is_default, created_at, updated_at`
var p types.PersonaConfig
var positiveRules, negativeRules, guardrailsConfig []byte
err := s.pool.QueryRow(ctx, query, args...).Scan(
&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI,
&p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism,
&positiveRules, &negativeRules, &p.Backstory, &p.WorldBuilding,
&guardrailsConfig, &p.TopicalRails, &p.Status,
&p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn,
&p.MoralCare, &p.MoralFairness, &p.MoralRights,
&p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity,
&p.IsDefault, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
p.PositiveRules = json.RawMessage(positiveRules)
p.NegativeRules = json.RawMessage(negativeRules)
p.GuardrailsConfig = json.RawMessage(guardrailsConfig)
s.logger.Info().Str("id", id.String()).Msg("Updated persona")
return &p, nil
}
// DeletePersona deletes a persona
func (s *PersonaService) DeletePersona(ctx context.Context, id, tenantID uuid.UUID) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM persona.personas WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return fmt.Errorf("delete failed: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("persona not found")
}
s.logger.Info().Str("id", id.String()).Msg("Deleted persona")
return nil
}
// GetSelfModel returns the self-model snapshot for a persona
func (s *PersonaService) GetSelfModel(ctx context.Context, personaID, tenantID uuid.UUID) (*types.SelfModelSnapshot, error) {
snapshot := &types.SelfModelSnapshot{
IdentityConstraints: make([]types.IdentityConstraint, 0),
Commitments: make([]types.PersonaCommitment, 0),
ConscienceStandards: make([]types.ConscienceStandard, 0),
}
// Identity constraints
rows, err := s.pool.Query(ctx, `
SELECT constraint_type, constraint_text, description, source, strength
FROM persona.identity_constraints
WHERE persona_id = $1 AND tenant_id = $2 AND is_active = true
ORDER BY strength DESC
`, personaID, tenantID)
if err != nil {
return nil, fmt.Errorf("constraints query failed: %w", err)
}
defer rows.Close()
for rows.Next() {
var c types.IdentityConstraint
var strength *float64
if err := rows.Scan(&c.ConstraintType, &c.ConstraintText, &c.Description, &c.Source, &strength); err != nil {
return nil, fmt.Errorf("constraint scan failed: %w", err)
}
if strength != nil {
c.Strength = *strength
} else {
c.Strength = 1.0
}
snapshot.IdentityConstraints = append(snapshot.IdentityConstraints, c)
}
// Commitments
//
// persona_commitments tracks session-bound commitments the assistant
// has made during conversation; it has no `source` or `strength`
// columns (the active flag is `status='active'`, not `is_active`).
// Synthesise both fields for the snapshot so the SelfModel contract
// stays stable for callers.
commitRows, err := s.pool.Query(ctx, `
SELECT commitment_text, COALESCE(commitment_type, '')
FROM persona.persona_commitments
WHERE persona_id = $1 AND tenant_id = $2 AND status = 'active'
ORDER BY created_at DESC
`, personaID, tenantID)
if err != nil {
return nil, fmt.Errorf("commitments query failed: %w", err)
}
defer commitRows.Close()
commitSource := "learned"
for commitRows.Next() {
var c types.PersonaCommitment
if err := commitRows.Scan(&c.CommitmentText, &c.CommitmentType); err != nil {
return nil, fmt.Errorf("commitment scan failed: %w", err)
}
c.Source = &commitSource
c.Strength = 1.0
snapshot.Commitments = append(snapshot.Commitments, c)
}
// Conscience standards
stdRows, err := s.pool.Query(ctx, `
SELECT standard_text, standard_type, moral_foundation, strength
FROM persona.conscience_standards
WHERE persona_id = $1 AND tenant_id = $2 AND is_active = true
ORDER BY strength DESC
`, personaID, tenantID)
if err != nil {
return nil, fmt.Errorf("standards query failed: %w", err)
}
defer stdRows.Close()
for stdRows.Next() {
var s types.ConscienceStandard
var strength *float64
if err := stdRows.Scan(&s.StandardText, &s.StandardType, &s.MoralFoundation, &strength); err != nil {
return nil, fmt.Errorf("standard scan failed: %w", err)
}
if strength != nil {
s.Strength = *strength
} else {
s.Strength = 1.0
}
snapshot.ConscienceStandards = append(snapshot.ConscienceStandards, s)
}
return snapshot, nil
}
// SearchExperiences returns experiences for a persona ordered by importance
func (s *PersonaService) SearchExperiences(ctx context.Context, personaID, tenantID uuid.UUID, limit int) ([]types.Experience, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, event_summary, event_type, occurred_at, place,
actors, outcome, outcome_detail,
emotional_valence, lesson_learned, importance_score
FROM persona.experiences
WHERE persona_id = $1 AND tenant_id = $2
ORDER BY importance_score DESC, occurred_at DESC
LIMIT $3
`, personaID, tenantID, limit)
if err != nil {
return nil, fmt.Errorf("experiences query failed: %w", err)
}
defer rows.Close()
experiences := make([]types.Experience, 0)
for rows.Next() {
var e types.Experience
if err := rows.Scan(
&e.ID, &e.EventSummary, &e.EventType, &e.OccurredAt, &e.Place,
&e.Actors, &e.Outcome, &e.OutcomeDetail,
&e.EmotionalValence, &e.LessonLearned, &e.ImportanceScore,
); err != nil {
return nil, fmt.Errorf("experience scan failed: %w", err)
}
experiences = append(experiences, e)
}
return experiences, nil
}
// GetEvaluations returns evaluations for a session
func (s *PersonaService) GetEvaluations(ctx context.Context, sessionID uuid.UUID, limit int) ([]types.Evaluation, error) {
if limit <= 0 || limit > 100 {
limit = 10
}
rows, err := s.pool.Query(ctx, `
SELECT e.role_fidelity, e.voice_consistency,
e.safety_compliance, e.character_break,
e.drift_score, e.evaluator_model, e.evaluated_at
FROM persona.evaluations e
JOIN persona.messages m ON m.id = e.message_id
WHERE m.session_id = $1
ORDER BY e.evaluated_at DESC
LIMIT $2
`, sessionID, limit)
if err != nil {
return nil, fmt.Errorf("evaluations query failed: %w", err)
}
defer rows.Close()
evaluations := make([]types.Evaluation, 0)
for rows.Next() {
var e types.Evaluation
if err := rows.Scan(
&e.RoleFidelity, &e.VoiceConsistency,
&e.SafetyCompliance, &e.CharacterBreak,
&e.DriftScore, &e.EvaluatorModel, &e.EvaluatedAt,
); err != nil {
return nil, fmt.Errorf("evaluation scan failed: %w", err)
}
evaluations = append(evaluations, e)
}
return evaluations, nil
}
// GetMoralPattern returns moral assessments for a session
func (s *PersonaService) GetMoralPattern(ctx context.Context, sessionID, tenantID uuid.UUID) ([]types.MoralAssessment, error) {
rows, err := s.pool.Query(ctx, `
SELECT activated_foundations, assessment_text,
has_tension, tension_foundations,
resolution_foundation, confidence
FROM persona.moral_assessments
WHERE session_id = $1 AND tenant_id = $2
ORDER BY created_at DESC
LIMIT 5
`, sessionID, tenantID)
if err != nil {
return nil, fmt.Errorf("moral pattern query failed: %w", err)
}
defer rows.Close()
assessments := make([]types.MoralAssessment, 0)
for rows.Next() {
var a types.MoralAssessment
var activatedFoundations []byte
if err := rows.Scan(
&activatedFoundations, &a.AssessmentText,
&a.HasTension, &a.TensionFoundations,
&a.ResolutionFoundation, &a.Confidence,
); err != nil {
return nil, fmt.Errorf("moral assessment scan failed: %w", err)
}
a.ActivatedFoundations = json.RawMessage(activatedFoundations)
assessments = append(assessments, a)
}
return assessments, nil
}

View File

@@ -0,0 +1,78 @@
package service
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PersonalAgentService handles personal agent config operations
type PersonalAgentService struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewPersonalAgentService creates a new personal agent service
func NewPersonalAgentService(pool *pgxpool.Pool, logger zerolog.Logger) *PersonalAgentService {
return &PersonalAgentService{
pool: pool,
logger: logger.With().Str("service", "personal_agent").Logger(),
}
}
// GetConfig gets a user's personal agent config
func (s *PersonalAgentService) GetConfig(ctx context.Context, userID, tenantID uuid.UUID) (*types.UserAgentConfig, error) {
var c types.UserAgentConfig
var configBytes []byte
err := s.pool.QueryRow(ctx,
`SELECT id, user_id, tenant_id, config, created_at, updated_at
FROM admin.user_agent_configs WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID).
Scan(&c.ID, &c.UserID, &c.TenantID, &configBytes, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("not found: %w", err)
}
c.Config = json.RawMessage(configBytes)
return &c, nil
}
// UpsertConfig creates or updates a user's personal agent config
func (s *PersonalAgentService) UpsertConfig(ctx context.Context, req *types.UserAgentConfigUpsert) (*types.UserAgentConfig, error) {
var c types.UserAgentConfig
var configBytes []byte
err := s.pool.QueryRow(ctx,
`INSERT INTO admin.user_agent_configs (user_id, tenant_id, config)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, tenant_id) DO UPDATE
SET config = EXCLUDED.config, updated_at = NOW()
RETURNING id, user_id, tenant_id, config, created_at, updated_at`,
req.UserID, req.TenantID, req.Config).
Scan(&c.ID, &c.UserID, &c.TenantID, &configBytes, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert failed: %w", err)
}
c.Config = json.RawMessage(configBytes)
s.logger.Info().Str("userId", req.UserID.String()).Str("tenantId", req.TenantID.String()).Msg("Upserted personal agent config")
return &c, nil
}
// DeleteConfig deletes a user's personal agent config
func (s *PersonalAgentService) DeleteConfig(ctx context.Context, userID, tenantID uuid.UUID) error {
tag, err := s.pool.Exec(ctx,
`DELETE FROM admin.user_agent_configs WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil {
return fmt.Errorf("delete failed: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("config not found")
}
s.logger.Info().Str("userId", userID.String()).Str("tenantId", tenantID.String()).Msg("Deleted personal agent config")
return nil
}

110
internal/service/pgp.go Normal file
View File

@@ -0,0 +1,110 @@
package service
import (
"bufio"
"strings"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// PGPService handles Hockeypuck PGP key operations
type PGPService struct {
client *client.HockeypuckClient
logger zerolog.Logger
}
// NewPGPService creates a new PGP service
func NewPGPService(hkpClient *client.HockeypuckClient, logger zerolog.Logger) *PGPService {
return &PGPService{
client: hkpClient,
logger: logger.With().Str("service", "pgp").Logger(),
}
}
// SearchKeys searches for PGP keys
func (s *PGPService) SearchKeys(query string) ([]types.PGPKey, error) {
result, err := s.client.SearchKeys(query)
if err != nil {
return nil, err
}
if result == "" {
return []types.PGPKey{}, nil
}
return parseMachineReadableIndex(result), nil
}
// GetKey retrieves a PGP key by key ID
func (s *PGPService) GetKey(keyID string) (*types.PGPKey, error) {
armoredKey, err := s.client.GetKey(keyID)
if err != nil {
return nil, err
}
if armoredKey == "" {
return nil, nil
}
return &types.PGPKey{
KeyID: keyID,
ArmoredKey: armoredKey,
}, nil
}
// UploadKey uploads a PGP public key
func (s *PGPService) UploadKey(keyText string) error {
return s.client.UploadKey(keyText)
}
// DeleteKey deletes a PGP key
func (s *PGPService) DeleteKey(keyID string) error {
return s.client.DeleteKey(keyID)
}
// parseMachineReadableIndex parses the HKP machine-readable index format
func parseMachineReadableIndex(data string) []types.PGPKey {
keys := []types.PGPKey{}
var current *types.PGPKey
scanner := bufio.NewScanner(strings.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Split(line, ":")
if len(fields) < 2 {
continue
}
switch fields[0] {
case "pub":
if current != nil {
keys = append(keys, *current)
}
current = &types.PGPKey{}
if len(fields) > 1 {
current.KeyID = fields[1]
}
if len(fields) > 2 {
current.Algorithm = fields[2]
}
if len(fields) > 4 {
current.Created = fields[4]
}
if len(fields) > 5 {
current.Expires = fields[5]
}
case "uid":
if current != nil && len(fields) > 1 {
current.UIDs = append(current.UIDs, fields[1])
}
}
}
if current != nil {
keys = append(keys, *current)
}
return keys
}

View File

@@ -0,0 +1,453 @@
package service
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// VoiceAgentService handles voice agent config and session operations
type VoiceAgentService struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewVoiceAgentService creates a new voice agent service
func NewVoiceAgentService(pool *pgxpool.Pool, logger zerolog.Logger) *VoiceAgentService {
return &VoiceAgentService{
pool: pool,
logger: logger.With().Str("service", "voice_agent").Logger(),
}
}
// ============================================================================
// Voice Agent Configs
// ============================================================================
// ListConfigs lists voice agent configs with optional filters
func (s *VoiceAgentService) ListConfigs(ctx context.Context, params types.ListParams, tenantID *uuid.UUID) ([]types.VoiceAgentConfig, int64, error) {
params = types.DefaultListParams(params)
countQuery := `SELECT COUNT(*) FROM voice_agent_configs WHERE 1=1`
listQuery := `SELECT id, tenant_id, agent_id, greeting_text, goodbye_text,
voice_id, language,
stt_provider, stt_model, tts_provider, tts_model,
max_call_duration_seconds, silence_timeout_seconds,
barge_in_enabled, vad_sensitivity,
transfer_enabled, transfer_number,
business_hours_enabled, business_hours, after_hours_text,
is_active, created_at, updated_at
FROM voice_agent_configs WHERE 1=1`
args := []interface{}{}
argIdx := 1
if tenantID != nil {
countQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx)
listQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx)
args = append(args, *tenantID)
argIdx++
}
if params.Search != "" {
countQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx)
listQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language 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()
configs := make([]types.VoiceAgentConfig, 0)
for rows.Next() {
var c types.VoiceAgentConfig
var businessHours []byte
if err := rows.Scan(
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
&c.VoiceID, &c.Language,
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
&c.BargeInEnabled, &c.VADSensitivity,
&c.TransferEnabled, &c.TransferNumber,
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
); err != nil {
return nil, 0, fmt.Errorf("scan failed: %w", err)
}
c.BusinessHours = json.RawMessage(businessHours)
configs = append(configs, c)
}
return configs, total, nil
}
// GetConfig gets a voice agent config by ID
func (s *VoiceAgentService) GetConfig(ctx context.Context, id uuid.UUID) (*types.VoiceAgentConfig, error) {
var c types.VoiceAgentConfig
var businessHours []byte
err := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, agent_id, greeting_text, goodbye_text,
voice_id, language,
stt_provider, stt_model, tts_provider, tts_model,
max_call_duration_seconds, silence_timeout_seconds,
barge_in_enabled, vad_sensitivity,
transfer_enabled, transfer_number,
business_hours_enabled, business_hours, after_hours_text,
is_active, created_at, updated_at
FROM voice_agent_configs WHERE id = $1`, id).
Scan(
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
&c.VoiceID, &c.Language,
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
&c.BargeInEnabled, &c.VADSensitivity,
&c.TransferEnabled, &c.TransferNumber,
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("not found: %w", err)
}
c.BusinessHours = json.RawMessage(businessHours)
return &c, nil
}
// CreateConfig creates a new voice agent config
func (s *VoiceAgentService) CreateConfig(ctx context.Context, req *types.VoiceAgentConfigCreate) (*types.VoiceAgentConfig, error) {
// Set defaults
greeting := req.GreetingText
if greeting == "" {
greeting = "Hello, how can I help you today?"
}
goodbye := req.GoodbyeText
if goodbye == "" {
goodbye = "Goodbye, have a great day."
}
voiceID := req.VoiceID
if voiceID == "" {
voiceID = "alloy"
}
lang := req.Language
if lang == "" {
lang = "en"
}
maxDuration := 1800
if req.MaxCallDurationSeconds != nil {
maxDuration = *req.MaxCallDurationSeconds
}
silenceTimeout := 30
if req.SilenceTimeoutSeconds != nil {
silenceTimeout = *req.SilenceTimeoutSeconds
}
bargeIn := true
if req.BargeInEnabled != nil {
bargeIn = *req.BargeInEnabled
}
vadSens := req.VADSensitivity
if vadSens == "" {
vadSens = "medium"
}
transfer := true
if req.TransferEnabled != nil {
transfer = *req.TransferEnabled
}
bizHoursEnabled := false
if req.BusinessHoursEnabled != nil {
bizHoursEnabled = *req.BusinessHoursEnabled
}
bizHours := req.BusinessHours
if len(bizHours) == 0 {
bizHours = json.RawMessage(`{}`)
}
var c types.VoiceAgentConfig
var businessHoursOut []byte
err := s.pool.QueryRow(ctx,
`INSERT INTO voice_agent_configs (
tenant_id, agent_id, greeting_text, goodbye_text,
voice_id, language,
stt_provider, stt_model, tts_provider, tts_model,
max_call_duration_seconds, silence_timeout_seconds,
barge_in_enabled, vad_sensitivity,
transfer_enabled, transfer_number,
business_hours_enabled, business_hours, after_hours_text
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text,
voice_id, language,
stt_provider, stt_model, tts_provider, tts_model,
max_call_duration_seconds, silence_timeout_seconds,
barge_in_enabled, vad_sensitivity,
transfer_enabled, transfer_number,
business_hours_enabled, business_hours, after_hours_text,
is_active, created_at, updated_at`,
req.TenantID, req.AgentID, greeting, goodbye,
voiceID, lang,
req.STTProvider, req.STTModel, req.TTSProvider, req.TTSModel,
maxDuration, silenceTimeout,
bargeIn, vadSens,
transfer, req.TransferNumber,
bizHoursEnabled, bizHours, req.AfterHoursText,
).Scan(
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
&c.VoiceID, &c.Language,
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
&c.BargeInEnabled, &c.VADSensitivity,
&c.TransferEnabled, &c.TransferNumber,
&c.BusinessHoursEnabled, &businessHoursOut, &c.AfterHoursText,
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
c.BusinessHours = json.RawMessage(businessHoursOut)
s.logger.Info().Str("id", c.ID.String()).Str("agentId", c.AgentID.String()).Msg("Created voice agent config")
return &c, nil
}
// UpdateConfig updates a voice agent config
func (s *VoiceAgentService) UpdateConfig(ctx context.Context, id uuid.UUID, req *types.VoiceAgentConfigUpdate) (*types.VoiceAgentConfig, error) {
// Build dynamic SET clause
setClauses := []string{}
args := []interface{}{}
argIdx := 1
addField := func(clause string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx))
args = append(args, val)
argIdx++
}
if req.GreetingText != nil {
addField("greeting_text", *req.GreetingText)
}
if req.GoodbyeText != nil {
addField("goodbye_text", *req.GoodbyeText)
}
if req.VoiceID != nil {
addField("voice_id", *req.VoiceID)
}
if req.Language != nil {
addField("language", *req.Language)
}
if req.STTProvider != nil {
addField("stt_provider", *req.STTProvider)
}
if req.STTModel != nil {
addField("stt_model", *req.STTModel)
}
if req.TTSProvider != nil {
addField("tts_provider", *req.TTSProvider)
}
if req.TTSModel != nil {
addField("tts_model", *req.TTSModel)
}
if req.MaxCallDurationSeconds != nil {
addField("max_call_duration_seconds", *req.MaxCallDurationSeconds)
}
if req.SilenceTimeoutSeconds != nil {
addField("silence_timeout_seconds", *req.SilenceTimeoutSeconds)
}
if req.BargeInEnabled != nil {
addField("barge_in_enabled", *req.BargeInEnabled)
}
if req.VADSensitivity != nil {
addField("vad_sensitivity", *req.VADSensitivity)
}
if req.TransferEnabled != nil {
addField("transfer_enabled", *req.TransferEnabled)
}
if req.TransferNumber != nil {
addField("transfer_number", *req.TransferNumber)
}
if req.BusinessHoursEnabled != nil {
addField("business_hours_enabled", *req.BusinessHoursEnabled)
}
if len(req.BusinessHours) > 0 {
addField("business_hours", req.BusinessHours)
}
if req.AfterHoursText != nil {
addField("after_hours_text", *req.AfterHoursText)
}
if req.IsActive != nil {
addField("is_active", *req.IsActive)
}
if len(setClauses) == 0 {
return s.GetConfig(ctx, id)
}
// Always update updated_at
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE voice_agent_configs SET %s WHERE id = $%d",
joinClauses(setClauses), argIdx)
args = append(args, id)
query += ` RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text,
voice_id, language,
stt_provider, stt_model, tts_provider, tts_model,
max_call_duration_seconds, silence_timeout_seconds,
barge_in_enabled, vad_sensitivity,
transfer_enabled, transfer_number,
business_hours_enabled, business_hours, after_hours_text,
is_active, created_at, updated_at`
var c types.VoiceAgentConfig
var businessHours []byte
err := s.pool.QueryRow(ctx, query, args...).Scan(
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
&c.VoiceID, &c.Language,
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
&c.BargeInEnabled, &c.VADSensitivity,
&c.TransferEnabled, &c.TransferNumber,
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
c.BusinessHours = json.RawMessage(businessHours)
s.logger.Info().Str("id", id.String()).Msg("Updated voice agent config")
return &c, nil
}
// DeleteConfig deletes a voice agent config
func (s *VoiceAgentService) DeleteConfig(ctx context.Context, id uuid.UUID) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM voice_agent_configs WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete failed: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("config not found")
}
s.logger.Info().Str("id", id.String()).Msg("Deleted voice agent config")
return nil
}
// ============================================================================
// Voice Sessions
// ============================================================================
// ListSessions lists voice sessions for a specific agent
func (s *VoiceAgentService) ListSessions(ctx context.Context, agentID uuid.UUID, params types.ListParams) ([]types.VoiceSession, int64, error) {
params = types.DefaultListParams(params)
var total int64
if err := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM voice_sessions WHERE agent_id = $1`, agentID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count query failed: %w", err)
}
rows, err := s.pool.Query(ctx,
`SELECT id, tenant_id, agent_id, caller_number, called_number,
asterisk_call_id, agent_session_id,
total_turns, stt_provider, tts_provider,
stt_audio_seconds, tts_characters,
started_at, ended_at, end_reason, metadata, created_at
FROM voice_sessions WHERE agent_id = $1
ORDER BY started_at DESC LIMIT $2 OFFSET $3`,
agentID, params.Limit, params.Offset)
if err != nil {
return nil, 0, fmt.Errorf("list query failed: %w", err)
}
defer rows.Close()
sessions := make([]types.VoiceSession, 0)
for rows.Next() {
var vs types.VoiceSession
var metadata []byte
if err := rows.Scan(
&vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber,
&vs.AsteriskCallID, &vs.AgentSessionID,
&vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider,
&vs.STTAudioSeconds, &vs.TTSCharacters,
&vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt,
); err != nil {
return nil, 0, fmt.Errorf("scan failed: %w", err)
}
vs.Metadata = json.RawMessage(metadata)
sessions = append(sessions, vs)
}
return sessions, total, nil
}
// GetSession gets a voice session by ID, including turns
func (s *VoiceAgentService) GetSession(ctx context.Context, sessionID uuid.UUID) (*types.VoiceSession, error) {
var vs types.VoiceSession
var metadata []byte
err := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, agent_id, caller_number, called_number,
asterisk_call_id, agent_session_id,
total_turns, stt_provider, tts_provider,
stt_audio_seconds, tts_characters,
started_at, ended_at, end_reason, metadata, created_at
FROM voice_sessions WHERE id = $1`, sessionID).
Scan(
&vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber,
&vs.AsteriskCallID, &vs.AgentSessionID,
&vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider,
&vs.STTAudioSeconds, &vs.TTSCharacters,
&vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("session not found: %w", err)
}
vs.Metadata = json.RawMessage(metadata)
// Fetch turns
turnRows, err := s.pool.Query(ctx,
`SELECT id, session_id, turn_number, role, text,
stt_confidence, agent_latency_ms, was_interrupted, created_at
FROM voice_session_turns WHERE session_id = $1
ORDER BY turn_number ASC`, sessionID)
if err != nil {
return nil, fmt.Errorf("turns query failed: %w", err)
}
defer turnRows.Close()
vs.Turns = make([]types.VoiceSessionTurn, 0)
for turnRows.Next() {
var t types.VoiceSessionTurn
if err := turnRows.Scan(
&t.ID, &t.SessionID, &t.TurnNumber, &t.Role, &t.Text,
&t.STTConfidence, &t.AgentLatencyMs, &t.WasInterrupted, &t.CreatedAt,
); err != nil {
return nil, fmt.Errorf("turn scan failed: %w", err)
}
vs.Turns = append(vs.Turns, t)
}
return &vs, nil
}
// joinClauses joins SQL SET clauses with commas
func joinClauses(clauses []string) string {
result := ""
for i, c := range clauses {
if i > 0 {
result += ", "
}
result += c
}
return result
}