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

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Build output
bin/
gsc-ops-api
server
*.bak
*.bak.*
# Local IDE/editor noise
.vscode/
.idea/
*.swp
*~
# Go test/coverage artefacts
*.test
*.out
coverage.html
# Local config overrides — actual secrets live in Infisical and TLS in /etc
*.local.yaml
.env
.env.*

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
APP_NAME := gsc-ops-api
VERSION := 1.0.0
BUILD_DIR := bin
GO := go
GOFLAGS := -trimpath
LDFLAGS := -s -w -X main.appVersion=$(VERSION)
.PHONY: all build clean deps test lint install
all: deps build
deps:
cd /srv/gosec/gsc-ops-api && $(GO) mod tidy
build:
cd /srv/gosec/gsc-ops-api && CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/server
clean:
rm -rf /srv/gosec/gsc-ops-api/$(BUILD_DIR)/$(APP_NAME)
test:
cd /srv/gosec/gsc-ops-api && $(GO) test ./...
lint:
cd /srv/gosec/gsc-ops-api && $(GO) vet ./...
install: build
bash /srv/gosec/gsc-ops-api/scripts/install.sh

View File

@@ -0,0 +1,13 @@
; Asterisk External Configuration (Realtime)
; Maps PJSIP sorcery objects and voicemail to ODBC backend
[settings]
; PJSIP Realtime Mappings
ps_endpoints => odbc,asterisk,ps_endpoints
ps_auths => odbc,asterisk,ps_auths
ps_aors => odbc,asterisk,ps_aors
ps_endpoint_id_ips => odbc,asterisk,ps_endpoint_id_ips
ps_registrations => odbc,asterisk,ps_registrations
; Voicemail
voicemail => odbc,asterisk,voicemail

View File

@@ -0,0 +1,180 @@
; Asterisk Dynamic Multi-Tenant Dialplan
; DID/route lookups via func_odbc from PostgreSQL
[general]
static=yes
writeprotect=no
clearglobalvars=no
[globals]
; Defaults — overridden per-tenant via DB lookups
DEFAULT_VM_CONTEXT=default
; ============================================================================
; [from-trunk] — Inbound DID calls from providers via Kamailio
; ============================================================================
[from-trunk]
; Inbound call: look up tenant by DID, then route
exten => _+X.,1,NoOp(Inbound trunk call to ${EXTEN})
same => n,Set(TENANT=${ODBC_TENANT_BY_DID(${EXTEN})})
same => n,GotoIf($["${TENANT}" = ""]?no-tenant,${EXTEN},1)
same => n,Set(CDR(accountcode)=${TENANT})
same => n,Set(ROUTE=${ODBC_INBOUND_ROUTE(${EXTEN})})
same => n,GotoIf($["${ROUTE}" = ""]?no-route,${EXTEN},1)
same => n,Set(DEST_TYPE=${CUT(ROUTE,|,1)})
same => n,Set(DEST_ID=${CUT(ROUTE,|,2)})
same => n,Set(DEST_DATA=${CUT(ROUTE,|,3)})
same => n,Goto(route-${DEST_TYPE},${EXTEN},1)
exten => _X.,1,NoOp(Inbound trunk call to ${EXTEN} - no + prefix)
same => n,Set(TENANT=${ODBC_TENANT_BY_DID(+${EXTEN})})
same => n,GotoIf($["${TENANT}" = ""]?no-tenant,${EXTEN},1)
same => n,Set(CDR(accountcode)=${TENANT})
same => n,Set(ROUTE=${ODBC_INBOUND_ROUTE(+${EXTEN})})
same => n,GotoIf($["${ROUTE}" = ""]?no-route,${EXTEN},1)
same => n,Set(DEST_TYPE=${CUT(ROUTE,|,1)})
same => n,Set(DEST_ID=${CUT(ROUTE,|,2)})
same => n,Set(DEST_DATA=${CUT(ROUTE,|,3)})
same => n,Goto(route-${DEST_TYPE},${EXTEN},1)
; ============================================================================
; Destination handlers — routed by destination_type from inbound_routes
; ============================================================================
; Route to extension
[route-extension]
exten => _X.,1,NoOp(Route to extension ${DEST_DATA})
same => n,Dial(PJSIP/${DEST_DATA},30,tTr)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n,VoiceMail(${DEST_DATA}@${TENANT},u)
same => n,Hangup()
same => n(busy),VoiceMail(${DEST_DATA}@${TENANT},b)
same => n,Hangup()
; Route to queue
[route-queue]
exten => _X.,1,NoOp(Route to queue ${DEST_DATA})
same => n,Queue(${DEST_DATA},tTr,,,300)
same => n,Hangup()
; Route to IVR
[route-ivr]
exten => _X.,1,NoOp(Route to IVR ${DEST_DATA})
same => n,Goto(ivr-${DEST_DATA},s,1)
same => n,Hangup()
; Route to voicemail
[route-voicemail]
exten => _X.,1,NoOp(Route to voicemail ${DEST_DATA})
same => n,Answer()
same => n,VoiceMail(${DEST_DATA}@${TENANT},u)
same => n,Hangup()
; Route to ring group
[route-ring_group]
exten => _X.,1,NoOp(Route to ring group ${DEST_DATA})
same => n,Dial(PJSIP/${DEST_DATA},30,tTr)
same => n,Hangup()
; Route to conference
[route-conference]
exten => _X.,1,NoOp(Route to conference ${DEST_DATA})
same => n,Answer()
same => n,ConfBridge(${DEST_DATA})
same => n,Hangup()
; Route to external number
[route-external]
exten => _X.,1,NoOp(Route to external number ${DEST_DATA})
same => n,Dial(PJSIP/${DEST_DATA}@kamailio-out,60,tTr)
same => n,Hangup()
; ============================================================================
; [outbound] — Outbound calls from extensions
; ============================================================================
[outbound]
; Outbound: 9 + number (strip 9, look up trunk by tenant)
exten => _9.,1,NoOp(Outbound call from ${CHANNEL(endpoint)} to ${EXTEN:1})
same => n,Set(CDR(accountcode)=${CHANNEL(accountcode)})
same => n,Set(TRUNK_INFO=${ODBC_OUTBOUND_ROUTE(${CDR(accountcode)},${EXTEN:1})})
same => n,GotoIf($["${TRUNK_INFO}" = ""]?no-trunk,${EXTEN},1)
same => n,Set(TRUNK_ID=${CUT(TRUNK_INFO,|,1)})
same => n,Dial(PJSIP/+${EXTEN:1}@kamailio-out,60,tTr)
same => n,Hangup()
; International: 00 + country code
exten => _900.,1,NoOp(International call to +${EXTEN:3})
same => n,Set(CDR(accountcode)=${CHANNEL(accountcode)})
same => n,Set(TRUNK_INFO=${ODBC_OUTBOUND_ROUTE(${CDR(accountcode)},+${EXTEN:3})})
same => n,GotoIf($["${TRUNK_INFO}" = ""]?no-trunk,${EXTEN},1)
same => n,Dial(PJSIP/+${EXTEN:3}@kamailio-out,60,tTr)
same => n,Hangup()
; ============================================================================
; [internal] — Internal extension-to-extension calls
; ============================================================================
[internal]
; Local extensions (4-digit)
exten => _XXXX,1,NoOp(Internal call to ${EXTEN})
same => n,Dial(PJSIP/${EXTEN},30,tTr)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n,VoiceMail(${EXTEN}@${CHANNEL(accountcode)},u)
same => n,Hangup()
same => n(busy),VoiceMail(${EXTEN}@${CHANNEL(accountcode)},b)
same => n,Hangup()
; Cross-server extension calls via Kamailio
exten => _XXXXX,1,NoOp(Cross-server call to ${EXTEN})
same => n,Dial(PJSIP/${EXTEN}@kamailio-out,30,tTr)
same => n,Hangup()
; Echo test
exten => 600,1,Answer()
same => n,Echo()
same => n,Hangup()
; Music on hold test
exten => 601,1,Answer()
same => n,MusicOnHold(default,60)
same => n,Hangup()
; Check voicemail
exten => *97,1,Answer()
same => n,VoiceMailMain(${CALLERID(num)}@${CHANNEL(accountcode)})
same => n,Hangup()
; Voicemail direct deposit
exten => *98,1,Answer()
same => n,VoiceMailMain(${EXTEN}@${CHANNEL(accountcode)},s)
same => n,Hangup()
; Include outbound dialing
include => outbound
; ============================================================================
; [from-kamailio] — Entry point for Kamailio-routed calls
; ============================================================================
[from-kamailio]
include => internal
include => from-trunk
; ============================================================================
; Error handlers
; ============================================================================
[no-tenant]
exten => _X.,1,NoOp(No tenant found for DID ${EXTEN})
same => n,Log(WARNING,No tenant found for DID ${EXTEN})
same => n,Playback(ss-noservice)
same => n,Hangup()
[no-route]
exten => _X.,1,NoOp(No route configured for DID ${EXTEN})
same => n,Log(WARNING,No inbound route for DID ${EXTEN} in tenant ${TENANT})
same => n,Playback(ss-noservice)
same => n,Hangup()
[no-trunk]
exten => _X.,1,NoOp(No outbound trunk found)
same => n,Log(WARNING,No outbound trunk for ${EXTEN} in tenant ${CDR(accountcode)})
same => n,Playback(ss-noservice)
same => n,Hangup()

View File

@@ -0,0 +1,25 @@
; Asterisk func_odbc Configuration
; Custom ODBC functions for multi-tenant DID routing
; TENANT_BY_DID - Lookup tenant code by DID number
; Usage: ${ODBC_TENANT_BY_DID(+3726026553)}
[TENANT_BY_DID]
dsn=asterisk
readsql=SELECT tenant_code FROM did_tenant_lookup WHERE did_number='${SQL_ESC(${ARG1})}' LIMIT 1
synopsis=Lookup tenant code by DID number
; INBOUND_ROUTE - Lookup inbound destination by DID and tenant
; Usage: ${ODBC_INBOUND_ROUTE(+3726026553)}
; Returns: destination_type|destination_id|destination_data
[INBOUND_ROUTE]
dsn=asterisk
readsql=SELECT destination_type || '|' || COALESCE(destination_id::text, '') || '|' || COALESCE(destination_data, '') FROM inbound_route_lookup WHERE did_number='${SQL_ESC(${ARG1})}' ORDER BY priority LIMIT 1
synopsis=Lookup inbound route by DID number
; OUTBOUND_ROUTE - Lookup outbound trunk for dialing
; Usage: ${ODBC_OUTBOUND_ROUTE(tenant_code,dialed_number)}
; Returns: trunk_id|trunk_host|trunk_port
[OUTBOUND_ROUTE]
dsn=asterisk
readsql=SELECT trunk_id || '|' || trunk_host || '|' || trunk_port FROM outbound_route_lookup WHERE tenant_code='${SQL_ESC(${ARG1})}' AND '${SQL_ESC(${ARG2})}' ~ ANY(dial_patterns) ORDER BY route_priority, trunk_priority LIMIT 1
synopsis=Lookup outbound trunk by tenant and dial pattern

11
configs/asterisk/odbc.ini Normal file
View File

@@ -0,0 +1,11 @@
[asterisk]
Description = Asterisk PostgreSQL Database
Driver = /usr/lib64/psqlodbcw.so
Servername = 172.17.3.14
Port = 5432
Database = asterisk
UserName = asterisk_reader
Password = gxiXVWr5t6anOw3Kzf5I66I84MZv6YuZ
ReadOnly = No
Debug = 0
CommLog = 0

View File

@@ -0,0 +1,75 @@
; PJSIP Configuration — Transport, Global, and ACL only
; All endpoints/auths/AORs are loaded from PostgreSQL via ODBC Realtime
[global]
type=global
user_agent=GoSec-PBX
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060
; ACL to allow only Kamailio servers
[kamailio-acl]
type=acl
permit=172.17.6.42/32
permit=172.17.6.43/32
permit=172.17.6.1/32
; === Kamailio Dispatcher Health Checks ===
; These remain static — Kamailio needs them for OPTIONS pings
[dispatcher]
type=endpoint
context=from-kamailio
disallow=all
allow=ulaw
allow=alaw
allow=g729
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
aors=dispatcher-aor
[dispatcher-aor]
type=aor
qualify_frequency=0
[identify-kamailio1]
type=identify
endpoint=dispatcher
match=172.17.6.42/32
[identify-kamailio2]
type=identify
endpoint=dispatcher
match=172.17.6.43/32
[identify-kamailio-vip]
type=identify
endpoint=dispatcher
match=172.17.6.1/32
; === Kamailio Outbound Endpoint ===
; Static — used by dialplan for outbound calls via Kamailio VIP
[kamailio-out]
type=endpoint
context=from-kamailio
disallow=all
allow=ulaw
allow=alaw
allow=g729
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
trust_id_inbound=yes
send_rpid=yes
acl=kamailio-acl
aors=kamailio-out-aor
[kamailio-out-aor]
type=aor
contact=sip:172.17.6.1:5060
qualify_frequency=30

View File

@@ -0,0 +1,30 @@
# Postfix satellite relay configuration for Asterisk voicemail-to-email
# Deployed to /etc/postfix/main.cf on pb01 (172.17.6.40) and pb02 (172.17.6.41)
# General
myhostname = HOSTNAME_PLACEHOLDER.gosec.internal
mydomain = gosec.internal
myorigin = gosec.cloud
mydestination =
mynetworks = 127.0.0.0/8
# Relay through mail server
relayhost = [172.17.4.11]:25
# Security
inet_interfaces = loopback-only
inet_protocols = ipv4
# TLS (optional — internal relay)
smtp_tls_security_level = none
# Aliases
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
# Limits
message_size_limit = 10485760
mailbox_size_limit = 0
# Logging
syslog_name = postfix-voicemail

View File

@@ -0,0 +1,13 @@
; Asterisk ODBC Resource Configuration
; Connects to PostgreSQL VIP for realtime config
[ENV]
[asterisk]
enabled => yes
dsn => asterisk
pre-connect => yes
max_connections => 10
username => asterisk_reader
password => PLACEHOLDER_FROM_INFISICAL
logging => no

View File

@@ -0,0 +1,23 @@
; Asterisk Sorcery Configuration
; Defines object-to-backend mappings for PJSIP
[res_pjsip]
; Static endpoints from pjsip.conf (dispatcher, kamailio-out, ACLs)
endpoint=config,pjsip.conf,criteria=type=endpoint
auth=config,pjsip.conf,criteria=type=auth
aor=config,pjsip.conf,criteria=type=aor
; Dynamic endpoints from PostgreSQL via ODBC realtime
endpoint=realtime,ps_endpoints
auth=realtime,ps_auths
aor=realtime,ps_aors
domain_alias=config,pjsip.conf,criteria=type=domain_alias
contact=astdb,registrar
; IP-based endpoint identification — static + realtime
[res_pjsip_endpoint_identifier_ip]
identify=config,pjsip.conf,criteria=type=identify
identify=realtime,ps_endpoint_id_ips
; Outbound registrations
[res_pjsip_outbound_registration]
registration=realtime,ps_registrations

View File

@@ -0,0 +1,45 @@
; Asterisk Voicemail Configuration
; Mailbox data loaded from PostgreSQL via ODBC Realtime (extconfig.conf)
; Email delivery via local Postfix satellite relay
[general]
format=wav49|wav
serveremail=voicemail@gosec.cloud
attach=yes
skipms=3000
maxsilence=10
silencethreshold=128
maxlogins=3
maxmsg=100
maxsecs=180
minsecs=3
saycid=yes
sayduration=yes
saydurationm=2
sendvoicemail=yes
review=yes
operator=no
envelope=yes
delete=no
nextaftercmd=yes
forcename=no
forcegreetings=no
; Email settings
emailsubject=New voicemail from ${VM_CALLERID} (${VM_DUR})
emailbody=Dear ${VM_NAME},\n\nYou have a new voicemail:\n\n From: ${VM_CALLERID}\n Date: ${VM_DATE}\n Duration: ${VM_DUR}\n Message: ${VM_MSGNUM}\n\nDial *97 to listen.\n\n--\nGoSec Cloud PBX
emaildateformat=%A, %B %d, %Y at %r
pagerdateformat=%A, %B %d, %Y at %r
mailcmd=/usr/sbin/sendmail -t
; Timezone
tz=utc
[zonemessages]
utc=UTC|'vm-received' q 'digits/at' H N
european=Europe/Helsinki|'vm-received' a d b 'digits/at' HM
eastern=America/New_York|'vm-received' Q 'digits/at' IMp
; Default context — mailbox data from realtime DB
; No static mailboxes needed; all loaded from pbx_voicemail_boxes via ODBC
[default]

89
configs/config.yaml Normal file
View File

@@ -0,0 +1,89 @@
server:
host: "0.0.0.0"
port: 8443
readTimeout: 30s
writeTimeout: 30s
database:
host: "172.17.3.14"
port: 5432
database: "gsc_core"
sslMode: "disable"
maxConns: 10
minConns: 2
databases:
core:
host: "172.17.3.14"
port: 5432
database: "gsc_core"
sslMode: "disable"
maxConns: 10
minConns: 2
pbx:
host: "172.17.3.14"
port: 5432
database: "gsc_pbx"
sslMode: "disable"
maxConns: 10
minConns: 2
voice_agent:
host: "172.17.3.14"
port: 5432
database: "gsc_ai_core"
schema: "voice_agent"
sslMode: "disable"
maxConns: 5
minConns: 1
persona:
host: "172.17.3.14"
port: 5432
database: "gsc_ai_core"
schema: "persona"
sslMode: "disable"
maxConns: 5
minConns: 1
tls:
certFile: "/etc/gsc-ops-api/tls/server.crt"
keyFile: "/etc/gsc-ops-api/tls/server.key"
caFile: "/etc/gsc-ops-api/tls/ca.crt"
ldap:
servers:
- "ldaps://fihelvid01.gosec.auth:636"
- "ldaps://fihelvid02.gosec.auth:636"
baseDn: "dc=gosec,dc=auth"
poolSize: 10
useTls: true
caFile: "/etc/pki/tls/certs/ca-bundle.crt"
powerdns:
baseUrl: "http://172.17.3.16:8081"
serverId: "localhost"
ejbca:
baseUrl: "https://172.17.3.41:8443"
certFile: "/etc/gsc-ops-api/tls/ejbca-client.crt"
keyFile: "/etc/gsc-ops-api/tls/ejbca-client.key"
caFile: "/etc/gsc-ops-api/tls/ca.crt"
hockeypuck:
servers:
- "http://172.16.1.21:11371"
- "http://172.16.1.22:11371"
- "http://172.16.1.23:11371"
infisical:
host: "https://denbgvsc01.gosec.internal"
projectId: "c150d657-2efc-4649-b51c-29cc71e72dee"
environment: "prod"
tokenFile: "/etc/gsc-ops-api/.infisical"
secretPath: "/"
auth:
apiKeys: []
logging:
level: "info"
format: "json"

38
go.mod Normal file
View File

@@ -0,0 +1,38 @@
module github.com/gosec/gsc-ops-api
go 1.24
toolchain go1.24.3
require (
github.com/go-ldap/ldap/v3 v3.4.10
github.com/gofiber/fiber/v2 v2.52.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/rs/zerolog v1.33.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

169
go.sum Normal file
View File

@@ -0,0 +1,169 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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
}

View File

@@ -0,0 +1,533 @@
-- PBX Multi-Tenant Realtime Views for Asterisk ODBC
-- Executed against the 'asterisk' database on PostgreSQL VIP 172.17.3.14
-- Prerequisites: postgres_fdw extension, asterisk_reader user
-- ============================================================================
-- Step 1: Create asterisk_reader user (run as superuser on primary)
-- ============================================================================
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'asterisk_reader') THEN
CREATE ROLE asterisk_reader WITH LOGIN PASSWORD 'PLACEHOLDER_CHANGE_ME';
END IF;
END $$;
-- ============================================================================
-- Step 2: Install extensions
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ============================================================================
-- Step 3: Create foreign server pointing to gsc_admin database
-- ============================================================================
DROP SERVER IF EXISTS gsc_admin_server CASCADE;
CREATE SERVER gsc_admin_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '127.0.0.1', port '5432', dbname 'gsc_admin');
-- User mapping: asterisk DB superuser -> gsc_admin reader
CREATE USER MAPPING IF NOT EXISTS FOR CURRENT_USER
SERVER gsc_admin_server
OPTIONS (user 'asterisk_reader', password 'PLACEHOLDER_CHANGE_ME');
-- ============================================================================
-- Step 4: Create foreign tables mirroring gsc_admin pbx_* tables
-- ============================================================================
DROP SCHEMA IF EXISTS gsc CASCADE;
CREATE SCHEMA gsc;
-- pbx_trunks
CREATE FOREIGN TABLE gsc.pbx_trunks (
id uuid,
tenant_id uuid,
provider_id uuid,
name varchar(128),
trunk_type varchar(16),
status varchar(16),
host varchar(256),
port int,
transport varchar(8),
username varchar(128),
password_encrypted bytea,
auth_realm varchar(128),
from_domain varchar(256),
from_user varchar(128),
register boolean,
register_frequency int,
codecs text[],
dtmf_mode varchar(16),
nat_mode varchar(16),
max_channels int,
context varchar(64),
trust_id_inbound boolean,
trust_id_outbound boolean,
send_pai boolean,
send_rpid boolean,
send_diversion boolean,
direct_media boolean,
rtp_symmetric boolean,
rewrite_contact boolean,
ice_support boolean,
force_rport boolean,
timers varchar(16),
timers_min_se int,
timers_sess_expires int,
media_encryption varchar(16),
media_encryption_optimistic boolean,
max_contacts int,
default_expiration int,
minimum_expiration int,
maximum_expiration int,
qualify_timeout numeric(5,2),
match_by_ip boolean,
match_ips text[],
outbound_caller_id_name varchar(64),
outbound_caller_id_number varchar(32),
t38_udptl boolean,
t38_udptl_ec varchar(16),
t38_udptl_maxdatagram int,
t38_udptl_nat boolean,
fax_detect boolean,
rtp_timeout int,
rtp_timeout_hold int,
rtp_keepalive int,
inband_progress boolean,
contact_user varchar(128),
line boolean,
client_uri varchar(256),
server_uri varchar(256),
outbound_proxy varchar(256),
registration_expiration int,
registration_retry_interval int,
pjsip_options jsonb,
is_active boolean,
created_at timestamptz,
updated_at timestamptz
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_trunks');
-- pbx_trunk_dids
CREATE FOREIGN TABLE gsc.pbx_trunk_dids (
id uuid,
trunk_id uuid,
tenant_id uuid,
did_number varchar(32),
description varchar(256),
destination_type varchar(32),
destination_id uuid,
is_active boolean
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_trunk_dids');
-- pbx_extensions
CREATE FOREIGN TABLE gsc.pbx_extensions (
id uuid,
tenant_id uuid,
user_id uuid,
extension varchar(16),
name varchar(128),
extension_type varchar(16),
caller_id_name varchar(64),
caller_id_number varchar(32),
outbound_caller_id_number varchar(32),
sip_username varchar(64),
sip_password_encrypted bytea,
transport varchar(8),
codecs text[],
dtmf_mode varchar(16),
nat_mode varchar(16),
ring_timeout int,
call_waiting boolean,
dnd_enabled boolean,
forward_all_enabled boolean,
forward_all_destination varchar(64),
forward_busy_enabled boolean,
forward_busy_destination varchar(64),
forward_no_answer_enabled boolean,
forward_no_answer_destination varchar(64),
forward_no_answer_timeout int,
voicemail_enabled boolean,
voicemail_id uuid,
recording_mode varchar(16),
pjsip_options jsonb,
is_active boolean,
created_at timestamptz,
updated_at timestamptz
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_extensions');
-- pbx_voicemail_boxes
CREATE FOREIGN TABLE gsc.pbx_voicemail_boxes (
id uuid,
tenant_id uuid,
extension_id uuid,
mailbox varchar(16),
name varchar(128),
pin_encrypted bytea,
email varchar(256),
email_attachment boolean,
email_delete_after_send boolean,
pager_email varchar(256),
max_message_length int,
max_messages int,
say_caller_id boolean,
say_duration boolean,
review_enabled boolean,
operator_enabled boolean,
envelope_enabled boolean,
timezone varchar(64),
mwi_enabled boolean,
is_active boolean,
created_at timestamptz
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_voicemail_boxes');
-- pbx_inbound_routes
CREATE FOREIGN TABLE gsc.pbx_inbound_routes (
id uuid,
tenant_id uuid,
name varchar(128),
did_number varchar(32),
caller_id_pattern varchar(64),
destination_type varchar(32),
destination_id uuid,
destination_data varchar(256),
priority int,
is_active boolean,
trunk_id uuid
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_inbound_routes');
-- pbx_outbound_routes
CREATE FOREIGN TABLE gsc.pbx_outbound_routes (
id uuid,
tenant_id uuid,
name varchar(128),
priority int,
dial_patterns text[],
prepend varchar(16),
prefix varchar(16),
strip_digits int,
override_caller_id boolean,
caller_id_name varchar(64),
caller_id_number varchar(32),
is_emergency boolean,
is_active boolean
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_outbound_routes');
-- pbx_outbound_route_trunks
CREATE FOREIGN TABLE gsc.pbx_outbound_route_trunks (
id uuid,
outbound_route_id uuid,
trunk_id uuid,
priority int
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_outbound_route_trunks');
-- pbx_tenant_settings
CREATE FOREIGN TABLE gsc.pbx_tenant_settings (
id uuid,
tenant_id uuid,
extension_length int,
extension_start int,
default_codecs text[],
default_outbound_cid_name varchar(64),
default_outbound_cid_number varchar(32),
default_recording_mode varchar(16),
voicemail_email_from varchar(256),
default_moh_class varchar(64),
default_language varchar(8),
timezone varchar(64),
max_extensions int,
max_trunks int,
max_concurrent_calls int
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_tenant_settings');
-- pbx_queues
CREATE FOREIGN TABLE gsc.pbx_queues (
id uuid,
tenant_id uuid,
extension varchar(16),
name varchar(128),
strategy varchar(32),
timeout int,
retry int,
wrapup_time int,
max_wait_time int,
max_callers int,
moh_class varchar(64),
is_active boolean
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_queues');
-- pbx_queue_members
CREATE FOREIGN TABLE gsc.pbx_queue_members (
id uuid,
queue_id uuid,
extension_id uuid,
penalty int,
paused boolean,
dynamic boolean,
membername varchar(128),
interface varchar(128),
state_interface varchar(128)
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'pbx_queue_members');
-- tenants (for name resolution)
CREATE FOREIGN TABLE gsc.tenants (
id uuid,
code varchar(32),
name varchar(128),
is_active boolean
) SERVER gsc_admin_server
OPTIONS (schema_name 'public', table_name 'tenants');
-- ============================================================================
-- Step 5: Create PJSIP Sorcery Views (Asterisk Realtime)
-- ============================================================================
-- ps_endpoints: PJSIP endpoint objects for trunks
CREATE OR REPLACE VIEW public.ps_endpoints AS
-- Trunk endpoints
SELECT
t.id::text AS id,
'endpoint' AS type,
COALESCE(t.transport, 'udp')::text AS transport,
COALESCE(t.context, 'from-trunk-' || ten.code) AS context,
CASE
WHEN array_length(t.codecs, 1) > 0
THEN array_to_string(t.codecs, ',')
ELSE 'ulaw,alaw'
END AS allow,
'all' AS disallow,
t.username AS auth,
t.id::text AS aors,
COALESCE(t.outbound_caller_id_name, '') || ' <' || COALESCE(t.outbound_caller_id_number, '') || '>' AS callerid,
CASE WHEN t.direct_media THEN 'yes' ELSE 'no' END AS direct_media,
CASE WHEN COALESCE(t.rtp_symmetric, true) THEN 'yes' ELSE 'no' END AS rtp_symmetric,
CASE WHEN COALESCE(t.force_rport, true) THEN 'yes' ELSE 'no' END AS force_rport,
CASE WHEN COALESCE(t.rewrite_contact, true) THEN 'yes' ELSE 'no' END AS rewrite_contact,
CASE WHEN t.trust_id_inbound THEN 'yes' ELSE 'no' END AS trust_id_inbound,
CASE WHEN t.send_rpid THEN 'yes' ELSE 'no' END AS send_rpid,
CASE WHEN t.send_pai THEN 'yes' ELSE 'no' END AS send_pai,
COALESCE(t.dtmf_mode, 'rfc4733')::text AS dtmf_mode,
CASE WHEN t.ice_support THEN 'yes' ELSE 'no' END AS ice_support,
CASE WHEN t.t38_udptl THEN 'yes' ELSE 'no' END AS t38_udptl,
COALESCE(t.t38_udptl_ec, 'none')::text AS t38_udptl_ec,
COALESCE(t.t38_udptl_maxdatagram, 0) AS t38_udptl_maxdatagram,
CASE WHEN t.t38_udptl_nat THEN 'yes' ELSE 'no' END AS t38_udptl_nat,
CASE WHEN COALESCE(t.inband_progress, false) THEN 'yes' ELSE 'no' END AS inband_progress,
COALESCE(t.timers, 'yes')::text AS timers,
COALESCE(t.timers_min_se, 90) AS timers_min_se,
COALESCE(t.timers_sess_expires, 1800) AS timers_sess_expires,
COALESCE(t.rtp_timeout, 60) AS rtp_timeout,
COALESCE(t.rtp_timeout_hold, 300) AS rtp_timeout_hold,
COALESCE(t.rtp_keepalive, 0) AS rtp_keepalive,
COALESCE(t.media_encryption, 'no')::text AS media_encryption,
CASE WHEN t.media_encryption_optimistic THEN 'yes' ELSE 'no' END AS media_encryption_optimistic,
t.tenant_id
FROM gsc.pbx_trunks t
JOIN gsc.tenants ten ON ten.id = t.tenant_id
WHERE t.is_active = true AND t.status = 'active';
-- ps_auths: PJSIP auth objects for trunks
CREATE OR REPLACE VIEW public.ps_auths AS
SELECT
t.id::text AS id,
'auth' AS type,
'userpass' AS auth_type,
t.username,
-- Password: stored as AES-256-GCM encrypted bytea in gsc_admin.
-- For ODBC realtime we store a plaintext copy in a separate field.
-- This view returns the username; actual password set via ops-api sync.
''::text AS password,
COALESCE(t.auth_realm, t.from_domain, t.host, '')::text AS realm,
t.tenant_id
FROM gsc.pbx_trunks t
JOIN gsc.tenants ten ON ten.id = t.tenant_id
WHERE t.is_active = true AND t.status = 'active'
AND t.username IS NOT NULL AND t.username != '';
-- ps_aors: PJSIP AOR (Address of Record) objects for trunks
CREATE OR REPLACE VIEW public.ps_aors AS
SELECT
t.id::text AS id,
'aor' AS type,
'sip:' || t.host || ':' || COALESCE(t.port, 5060) AS contact,
COALESCE(t.max_contacts, 1) AS max_contacts,
COALESCE(t.qualify_timeout, 3.0)::numeric(5,2) AS qualify_timeout,
30 AS qualify_frequency,
COALESCE(t.default_expiration, 3600) AS default_expiration,
COALESCE(t.minimum_expiration, 60) AS minimum_expiration,
COALESCE(t.maximum_expiration, 7200) AS maximum_expiration,
t.tenant_id
FROM gsc.pbx_trunks t
WHERE t.is_active = true AND t.status = 'active';
-- ps_endpoint_id_ips: IP-based endpoint identification for trunks
CREATE OR REPLACE VIEW public.ps_endpoint_id_ips AS
SELECT
t.id::text AS endpoint,
ip AS match,
t.tenant_id
FROM gsc.pbx_trunks t,
LATERAL unnest(t.match_ips) AS ip
WHERE t.is_active = true AND t.status = 'active'
AND t.match_by_ip = true
AND t.match_ips IS NOT NULL
AND array_length(t.match_ips, 1) > 0;
-- ps_registrations: outbound registration objects for trunks that register
CREATE OR REPLACE VIEW public.ps_registrations AS
SELECT
t.id::text AS id,
'registration' AS type,
COALESCE(t.transport, 'udp')::text AS transport,
COALESCE(t.outbound_proxy, '')::text AS outbound_proxy,
COALESCE(t.server_uri, 'sip:' || t.host || ':' || COALESCE(t.port, 5060))::text AS server_uri,
COALESCE(t.client_uri, 'sip:' || t.username || '@' || COALESCE(t.from_domain, t.host))::text AS client_uri,
COALESCE(t.contact_user, t.username, '')::text AS contact_user,
COALESCE(t.registration_expiration, 3600) AS expiration,
COALESCE(t.registration_retry_interval, 60) AS retry_interval,
CASE WHEN COALESCE(t.line, false) THEN 'yes' ELSE 'no' END AS line,
t.id::text AS outbound_auth,
t.tenant_id
FROM gsc.pbx_trunks t
WHERE t.is_active = true AND t.status = 'active'
AND t.register = true;
-- voicemail: Asterisk voicemail boxes
CREATE OR REPLACE VIEW public.voicemail AS
SELECT
vm.id::text AS uniqueid,
ten.code AS context,
vm.mailbox,
''::text AS password,
vm.name AS fullname,
COALESCE(vm.email, '') AS email,
COALESCE(vm.pager_email, '') AS pager,
CASE WHEN COALESCE(vm.email_attachment, true) THEN 'yes' ELSE 'no' END AS attach,
CASE WHEN COALESCE(vm.email_delete_after_send, false) THEN 'yes' ELSE 'no' END AS "delete",
COALESCE(vm.max_message_length, 180) AS maxsecs,
COALESCE(vm.max_messages, 100) AS maxmsg,
COALESCE(vm.timezone, 'UTC') AS tz,
CASE WHEN COALESCE(vm.say_caller_id, true) THEN 'yes' ELSE 'no' END AS saycid,
CASE WHEN COALESCE(vm.say_duration, true) THEN 'yes' ELSE 'no' END AS sayduration,
CASE WHEN COALESCE(vm.review_enabled, true) THEN 'yes' ELSE 'no' END AS review,
CASE WHEN COALESCE(vm.operator_enabled, true) THEN 'yes' ELSE 'no' END AS operator,
CASE WHEN COALESCE(vm.envelope_enabled, true) THEN 'yes' ELSE 'no' END AS envelope,
vm.tenant_id
FROM gsc.pbx_voicemail_boxes vm
JOIN gsc.tenants ten ON ten.id = vm.tenant_id
WHERE vm.is_active = true;
-- ============================================================================
-- Step 6: func_odbc lookup views
-- ============================================================================
-- Tenant lookup by DID number
CREATE OR REPLACE VIEW public.did_tenant_lookup AS
SELECT
td.did_number,
td.tenant_id,
ten.code AS tenant_code
FROM gsc.pbx_trunk_dids td
JOIN gsc.tenants ten ON ten.id = td.tenant_id
WHERE td.is_active = true;
-- Inbound route lookup by DID
CREATE OR REPLACE VIEW public.inbound_route_lookup AS
SELECT
ir.did_number,
ir.tenant_id,
ten.code AS tenant_code,
ir.destination_type,
ir.destination_id,
ir.destination_data,
ir.priority
FROM gsc.pbx_inbound_routes ir
JOIN gsc.tenants ten ON ten.id = ir.tenant_id
WHERE ir.is_active = true
ORDER BY ir.priority ASC;
-- Outbound route lookup with trunk priority
CREATE OR REPLACE VIEW public.outbound_route_lookup AS
SELECT
obr.id AS route_id,
obr.tenant_id,
ten.code AS tenant_code,
obr.dial_patterns,
obr.prepend,
obr.prefix,
obr.strip_digits,
obr.priority AS route_priority,
obr.override_caller_id,
obr.caller_id_name,
obr.caller_id_number,
obr.is_emergency,
ort.trunk_id,
ort.priority AS trunk_priority,
t.host AS trunk_host,
t.port AS trunk_port,
t.username AS trunk_username
FROM gsc.pbx_outbound_routes obr
JOIN gsc.tenants ten ON ten.id = obr.tenant_id
LEFT JOIN gsc.pbx_outbound_route_trunks ort ON ort.outbound_route_id = obr.id
LEFT JOIN gsc.pbx_trunks t ON t.id = ort.trunk_id
WHERE obr.is_active = true
ORDER BY obr.priority ASC, ort.priority ASC;
-- ============================================================================
-- Step 7: SIP password sync table (plaintext for Asterisk ODBC)
-- ============================================================================
-- Asterisk cannot decrypt AES-256-GCM from the DB. We keep a sync table
-- that ops-api populates with plaintext passwords.
CREATE TABLE IF NOT EXISTS public.sip_passwords (
entity_id uuid PRIMARY KEY,
entity_type varchar(16) NOT NULL DEFAULT 'trunk', -- 'trunk' or 'extension'
password text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT NOW()
);
-- Update ps_auths to use sip_passwords
CREATE OR REPLACE VIEW public.ps_auths AS
SELECT
t.id::text AS id,
'auth' AS type,
'userpass' AS auth_type,
t.username,
COALESCE(sp.password, '')::text AS password,
COALESCE(t.auth_realm, t.from_domain, t.host, '')::text AS realm,
t.tenant_id
FROM gsc.pbx_trunks t
JOIN gsc.tenants ten ON ten.id = t.tenant_id
LEFT JOIN public.sip_passwords sp ON sp.entity_id = t.id AND sp.entity_type = 'trunk'
WHERE t.is_active = true AND t.status = 'active'
AND t.username IS NOT NULL AND t.username != '';
-- ============================================================================
-- Step 8: Grant permissions
-- ============================================================================
GRANT USAGE ON SCHEMA public TO asterisk_reader;
GRANT USAGE ON SCHEMA gsc TO asterisk_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO asterisk_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA gsc TO asterisk_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO asterisk_reader;
-- Allow asterisk_reader to read sip_passwords (and in future, insert/update via ops-api)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.sip_passwords TO asterisk_reader;
-- ============================================================================
-- Step 9: pg_hba.conf additions (must be done via Patroni config)
-- Add these lines to patroni.yml postgresql.pg_hba section:
-- - host asterisk asterisk_reader 172.17.6.40/32 scram-sha-256
-- - host asterisk asterisk_reader 172.17.6.41/32 scram-sha-256
-- - host asterisk asterisk_reader 172.17.8.20/32 scram-sha-256
-- - host gsc_admin asterisk_reader 172.17.3.11/32 scram-sha-256
-- - host gsc_admin asterisk_reader 172.17.3.12/32 scram-sha-256
-- - host gsc_admin asterisk_reader 172.17.3.13/32 scram-sha-256
-- ============================================================================

65
pkg/types/errors.go Normal file
View File

@@ -0,0 +1,65 @@
package types
import "fmt"
// Standard error codes for REST API responses
const (
CodeBadRequest = "BAD_REQUEST"
CodeUnauthorized = "UNAUTHORIZED"
CodeForbidden = "FORBIDDEN"
CodeNotFound = "NOT_FOUND"
CodeConflict = "CONFLICT"
CodeValidation = "VALIDATION_ERROR"
CodeInternal = "INTERNAL_ERROR"
CodeServiceUnavail = "SERVICE_UNAVAILABLE"
CodeTimeout = "TIMEOUT"
)
// APIError represents a structured REST API error
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"requestId,omitempty"`
Detail string `json:"detail,omitempty"`
Status int `json:"-"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func NewBadRequest(msg string) *APIError {
return &APIError{Code: CodeBadRequest, Message: msg, Status: 400}
}
func NewUnauthorized(msg string) *APIError {
return &APIError{Code: CodeUnauthorized, Message: msg, Status: 401}
}
func NewForbidden(msg string) *APIError {
return &APIError{Code: CodeForbidden, Message: msg, Status: 403}
}
func NewNotFound(msg string) *APIError {
return &APIError{Code: CodeNotFound, Message: msg, Status: 404}
}
func NewConflict(msg string) *APIError {
return &APIError{Code: CodeConflict, Message: msg, Status: 409}
}
func NewValidation(msg string) *APIError {
return &APIError{Code: CodeValidation, Message: msg, Status: 422}
}
func NewInternal(msg string) *APIError {
return &APIError{Code: CodeInternal, Message: msg, Status: 500}
}
func NewServiceUnavailable(msg string) *APIError {
return &APIError{Code: CodeServiceUnavail, Message: msg, Status: 503}
}
func NewTimeout(msg string) *APIError {
return &APIError{Code: CodeTimeout, Message: msg, Status: 504}
}

396
pkg/types/models.go Normal file
View File

@@ -0,0 +1,396 @@
package types
import (
"time"
"github.com/google/uuid"
)
// LDAPUser represents a FreeIPA user
type LDAPUser struct {
UID string `json:"uid"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Phone string `json:"phone,omitempty"`
Title string `json:"title,omitempty"`
Disabled bool `json:"disabled"`
Groups []string `json:"groups,omitempty"`
Shell string `json:"shell,omitempty"`
HomeDir string `json:"homeDir,omitempty"`
// Extended GoSec service attributes grouped by domain
Services map[string]map[string]interface{} `json:"services,omitempty"`
ObjectClasses []string `json:"objectClasses,omitempty"`
}
// LDAPUserCreate is the request body for creating a user
type LDAPUserCreate struct {
UID string `json:"uid" validate:"required"`
FirstName string `json:"firstName" validate:"required"`
LastName string `json:"lastName" validate:"required"`
Email string `json:"email"`
Password string `json:"password,omitempty"`
Phone string `json:"phone,omitempty"`
Title string `json:"title,omitempty"`
Shell string `json:"shell,omitempty"`
Services map[string]map[string]interface{} `json:"services,omitempty"`
}
// LDAPUserUpdate is the request body for updating a user
type LDAPUserUpdate struct {
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Title *string `json:"title,omitempty"`
Shell *string `json:"shell,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Services map[string]map[string]interface{} `json:"services,omitempty"`
}
// LDAPGroup represents a FreeIPA group
type LDAPGroup struct {
CN string `json:"cn"`
Description string `json:"description,omitempty"`
Members []string `json:"members,omitempty"`
GIDNumber string `json:"gidNumber,omitempty"`
}
// LDAPGroupCreate is the request body for creating a group
type LDAPGroupCreate struct {
CN string `json:"cn" validate:"required"`
Description string `json:"description,omitempty"`
}
// LDAPGroupUpdate is the request body for updating a group
type LDAPGroupUpdate struct {
Description *string `json:"description,omitempty"`
}
// LDAPGroupMemberAdd is the request body for adding members
type LDAPGroupMemberAdd struct {
Members []string `json:"members" validate:"required"`
}
// PasswordReset is the request body for resetting a password
type PasswordReset struct {
NewPassword string `json:"newPassword" validate:"required"`
}
// DNSZone represents a PowerDNS zone
type DNSZone struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
DNSSec bool `json:"dnssec"`
Serial int64 `json:"serial"`
NotifiedSerial int64 `json:"notifiedSerial"`
Records []DNSRecord `json:"records,omitempty"`
SOAEdit string `json:"soaEdit,omitempty"`
SOAEditAPI string `json:"soaEditApi,omitempty"`
}
// DNSRecord represents a DNS resource record set
type DNSRecord struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
Records []DNSRecordEntry `json:"records"`
Comments []DNSRecordComment `json:"comments,omitempty"`
}
// DNSRecordEntry represents a single record within an RRset
type DNSRecordEntry struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
// DNSRecordComment represents a comment on an RRset
type DNSRecordComment struct {
Content string `json:"content"`
Account string `json:"account"`
ModifiedAt int64 `json:"modified_at"`
}
// DNSZoneCreate is the request body for creating a zone
type DNSZoneCreate struct {
Name string `json:"name" validate:"required"`
Kind string `json:"kind"`
Nameservers []string `json:"nameservers"`
Masters []string `json:"masters,omitempty"`
}
// DNSZoneUpdate is the request body for updating zone metadata
type DNSZoneUpdate struct {
Kind *string `json:"kind,omitempty"`
Masters []string `json:"masters,omitempty"`
}
// DNSRecordChange is the request body for creating/updating/deleting records
type DNSRecordChange struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required"`
TTL int `json:"ttl"`
ChangeType string `json:"changetype" validate:"required"`
Records []DNSRecordEntry `json:"records"`
}
// DomainSetup is the request body for orchestrated domain setup
type DomainSetup struct {
Domain string `json:"domain" validate:"required"`
MXHost string `json:"mxHost,omitempty"`
DKIMKey string `json:"dkimKey,omitempty"`
SPFIncludes []string `json:"spfIncludes,omitempty"`
}
// DomainVerify is the request body for domain verification
type DomainVerify struct {
Domain string `json:"domain" validate:"required"`
}
// DomainVerifyResult is the response for domain verification
type DomainVerifyResult struct {
Domain string `json:"domain"`
Results map[string]string `json:"results"`
AllOK bool `json:"allOk"`
}
// Tenant represents a database tenant record
type Tenant struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customerId"`
Code string `json:"code"`
Name string `json:"name"`
DisplayName *string `json:"displayName,omitempty"`
Domain *string `json:"domain,omitempty"`
LogoURL *string `json:"logoUrl,omitempty"`
PrimaryColor *string `json:"primaryColor,omitempty"`
MaxUsers *int `json:"maxUsers,omitempty"`
MaxStorageGB *int `json:"maxStorageGb,omitempty"`
MaxRecordingHours *int `json:"maxRecordingHours,omitempty"`
IsActive *bool `json:"isActive"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// TenantCreate is the request body for creating a tenant
type TenantCreate struct {
CustomerID uuid.UUID `json:"customerId" validate:"required"`
Code string `json:"code" validate:"required"`
Name string `json:"name" validate:"required"`
DisplayName string `json:"displayName,omitempty"`
Domain string `json:"domain,omitempty"`
LogoURL string `json:"logoUrl,omitempty"`
PrimaryColor string `json:"primaryColor,omitempty"`
MaxUsers *int `json:"maxUsers,omitempty"`
MaxStorageGB *int `json:"maxStorageGb,omitempty"`
MaxRecordingHours *int `json:"maxRecordingHours,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// TenantUpdate is the request body for updating a tenant
type TenantUpdate struct {
Name *string `json:"name,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Domain *string `json:"domain,omitempty"`
LogoURL *string `json:"logoUrl,omitempty"`
PrimaryColor *string `json:"primaryColor,omitempty"`
MaxUsers *int `json:"maxUsers,omitempty"`
MaxStorageGB *int `json:"maxStorageGb,omitempty"`
MaxRecordingHours *int `json:"maxRecordingHours,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// DBUser represents a database user record
type DBUser struct {
ID uuid.UUID `json:"id"`
GscSID string `json:"gscsid"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Email *string `json:"email,omitempty"`
Timezone *string `json:"timezone,omitempty"`
Locale *string `json:"locale,omitempty"`
Status *string `json:"status,omitempty"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// DBUserCreate is the request body for creating a user record
type DBUserCreate struct {
GscSID string `json:"gscsid" validate:"required"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
Timezone string `json:"timezone,omitempty"`
Locale string `json:"locale,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// DBUserUpdate is the request body for updating a user record
type DBUserUpdate struct {
Timezone *string `json:"timezone,omitempty"`
Locale *string `json:"locale,omitempty"`
Status *string `json:"status,omitempty"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// Certificate represents an EJBCA certificate
type Certificate struct {
SerialNumber string `json:"serialNumber"`
SubjectDN string `json:"subjectDn"`
IssuerDN string `json:"issuerDn"`
Status string `json:"status"`
NotBefore time.Time `json:"notBefore"`
NotAfter time.Time `json:"notAfter"`
KeyAlgorithm string `json:"keyAlgorithm,omitempty"`
CAName string `json:"caName,omitempty"`
CertProfileID int `json:"certProfileId,omitempty"`
}
// CertRequest is the request body for requesting a certificate
type CertRequest struct {
SubjectDN string `json:"subjectDn" validate:"required"`
CAName string `json:"caName" validate:"required"`
CertProfileName string `json:"certProfileName" validate:"required"`
EndEntityName string `json:"endEntityName" validate:"required"`
KeyAlgorithm string `json:"keyAlgorithm,omitempty"`
SANs []string `json:"sans,omitempty"`
}
// CertRenew is the request body for renewing a certificate
type CertRenew struct {
CSR string `json:"csr,omitempty"`
}
// CertRevoke is the request body for revoking a certificate
type CertRevoke struct {
Reason string `json:"reason"`
IssuerDN string `json:"issuerDn"`
}
// LDAPEntity represents a generic LDAP entity (tenant, policy, key, etc.)
type LDAPEntity struct {
DN string `json:"dn"`
Type string `json:"type"`
RDN string `json:"rdn"`
ObjectClasses []string `json:"objectClasses,omitempty"`
Attributes map[string]interface{} `json:"attributes"`
}
// LDAPEntityCreate is the request body for creating an entity
type LDAPEntityCreate struct {
Attributes map[string]interface{} `json:"attributes" validate:"required"`
}
// LDAPEntityUpdate is the request body for updating an entity
type LDAPEntityUpdate struct {
Attributes map[string]interface{} `json:"attributes" validate:"required"`
}
// PGPKey represents a Hockeypuck PGP key
type PGPKey struct {
KeyID string `json:"keyId"`
Fingerprint string `json:"fingerprint"`
Algorithm string `json:"algorithm,omitempty"`
Length int `json:"length,omitempty"`
Created string `json:"created,omitempty"`
Expires string `json:"expires,omitempty"`
UIDs []string `json:"uids,omitempty"`
ArmoredKey string `json:"armoredKey,omitempty"`
}
// PGPKeyUpload is the request body for uploading a PGP key
type PGPKeyUpload struct {
KeyText string `json:"keyText" validate:"required"`
}
// CardDAVPrincipal represents a sabre/dav principal
type CardDAVPrincipal struct {
ID int `json:"id"`
URI string `json:"uri"`
Email string `json:"email,omitempty"`
DisplayName string `json:"displayName,omitempty"`
}
// CardDAVPrincipalCreate is the request body for creating a principal
type CardDAVPrincipalCreate struct {
Username string `json:"username" validate:"required"`
Email string `json:"email,omitempty"`
DisplayName string `json:"displayName,omitempty"`
}
// AddressBook represents a sabre/dav address book
type AddressBook struct {
ID int `json:"id"`
PrincipalURI string `json:"principalUri"`
DisplayName string `json:"displayName"`
URI string `json:"uri"`
Description string `json:"description,omitempty"`
SyncToken int `json:"syncToken"`
}
// AddressBookCreate is the request body for creating an address book
type AddressBookCreate struct {
PrincipalURI string `json:"principalUri" validate:"required"`
DisplayName string `json:"displayName" validate:"required"`
URI string `json:"uri" validate:"required"`
Description string `json:"description,omitempty"`
}
// AddressBookUpdate is the request body for updating an address book
type AddressBookUpdate struct {
DisplayName *string `json:"displayName,omitempty"`
Description *string `json:"description,omitempty"`
}
// Contact represents a sabre/dav contact (card)
type Contact struct {
ID int `json:"id"`
AddressBookID int `json:"addressbookId"`
CardData string `json:"cardData,omitempty"`
URI string `json:"uri"`
LastModified int `json:"lastModified,omitempty"`
ETag string `json:"etag"`
Size int `json:"size"`
}
// ContactCreate is the request body for creating a contact
type ContactCreate struct {
URI string `json:"uri" validate:"required"`
CardData string `json:"cardData" validate:"required"`
}
// ContactUpdate is the request body for updating a contact
type ContactUpdate struct {
CardData string `json:"cardData" validate:"required"`
}
// ListParams represents common query parameters for list endpoints
type ListParams struct {
Limit int `query:"limit"`
Offset int `query:"offset"`
Search string `query:"search"`
Status string `query:"status"`
}
// DefaultListParams returns list params with defaults applied
func DefaultListParams(p ListParams) ListParams {
if p.Limit <= 0 || p.Limit > 100 {
p.Limit = 50
}
if p.Offset < 0 {
p.Offset = 0
}
return p
}

283
pkg/types/pbx.go Normal file
View File

@@ -0,0 +1,283 @@
package types
import (
"time"
"github.com/google/uuid"
)
// PBXTrunk represents a SIP trunk configuration
type PBXTrunk struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
ProviderID *uuid.UUID `json:"providerId,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
TrunkType string `json:"trunkType"`
Status string `json:"status"`
Host string `json:"host"`
Port int `json:"port"`
Transport string `json:"transport"`
Username string `json:"username,omitempty"`
AuthRealm string `json:"authRealm,omitempty"`
FromDomain string `json:"fromDomain,omitempty"`
FromUser string `json:"fromUser,omitempty"`
Register bool `json:"register"`
Codecs []string `json:"codecs"`
DtmfMode string `json:"dtmfMode"`
NatMode string `json:"natMode,omitempty"`
MaxChannels *int `json:"maxChannels,omitempty"`
CurrentChannels *int `json:"currentChannels,omitempty"`
OutboundCallerIDName string `json:"outboundCallerIdName,omitempty"`
OutboundCallerIDNum string `json:"outboundCallerIdNumber,omitempty"`
Priority *int `json:"priority,omitempty"`
DIDCount int `json:"didCount,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// PBXTrunkCreate is the request body for creating a trunk
type PBXTrunkCreate struct {
TenantID uuid.UUID `json:"tenantId" validate:"required"`
ProviderID *uuid.UUID `json:"providerId,omitempty"`
Name string `json:"name" validate:"required"`
Description string `json:"description,omitempty"`
Host string `json:"host" validate:"required"`
Port int `json:"port,omitempty"`
Transport string `json:"transport,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AuthRealm string `json:"authRealm,omitempty"`
FromDomain string `json:"fromDomain,omitempty"`
FromUser string `json:"fromUser,omitempty"`
Register bool `json:"register"`
Codecs []string `json:"codecs,omitempty"`
DtmfMode string `json:"dtmfMode,omitempty"`
NatMode string `json:"natMode,omitempty"`
MaxChannels *int `json:"maxChannels,omitempty"`
OutboundCallerIDName string `json:"outboundCallerIdName,omitempty"`
OutboundCallerIDNum string `json:"outboundCallerIdNumber,omitempty"`
}
// PBXTrunkUpdate is the request body for updating a trunk
type PBXTrunkUpdate struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Host *string `json:"host,omitempty"`
Port *int `json:"port,omitempty"`
Transport *string `json:"transport,omitempty"`
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"`
AuthRealm *string `json:"authRealm,omitempty"`
FromDomain *string `json:"fromDomain,omitempty"`
FromUser *string `json:"fromUser,omitempty"`
Register *bool `json:"register,omitempty"`
Codecs []string `json:"codecs,omitempty"`
DtmfMode *string `json:"dtmfMode,omitempty"`
NatMode *string `json:"natMode,omitempty"`
MaxChannels *int `json:"maxChannels,omitempty"`
OutboundCallerIDName *string `json:"outboundCallerIdName,omitempty"`
OutboundCallerIDNum *string `json:"outboundCallerIdNumber,omitempty"`
Status *string `json:"status,omitempty"`
}
// PBXTrunkDID represents a DID number assigned to a trunk
type PBXTrunkDID struct {
ID uuid.UUID `json:"id"`
TrunkID uuid.UUID `json:"trunkId"`
TenantID uuid.UUID `json:"tenantId"`
DIDNumber string `json:"didNumber"`
Description string `json:"description,omitempty"`
DestinationType string `json:"destinationType,omitempty"`
DestinationID *uuid.UUID `json:"destinationId,omitempty"`
IsActive bool `json:"isActive"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
}
// PBXTrunkDIDCreate is the request body for adding a DID to a trunk
type PBXTrunkDIDCreate struct {
DIDNumber string `json:"didNumber" validate:"required"`
TenantID uuid.UUID `json:"tenantId" validate:"required"`
Description string `json:"description,omitempty"`
DestinationType string `json:"destinationType,omitempty"`
DestinationID *uuid.UUID `json:"destinationId,omitempty"`
}
// PBXExtension represents a PBX extension
type PBXExtension struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
UserID *uuid.UUID `json:"userId,omitempty"`
Extension string `json:"extension"`
Name string `json:"name"`
ExtensionType string `json:"extensionType"`
CallerIDName string `json:"callerIdName,omitempty"`
CallerIDNumber string `json:"callerIdNumber,omitempty"`
OutboundCallerIDNumber string `json:"outboundCallerIdNumber,omitempty"`
SIPUsername string `json:"sipUsername,omitempty"`
Transport string `json:"transport"`
Codecs []string `json:"codecs"`
VoicemailEnabled bool `json:"voicemailEnabled"`
IsActive bool `json:"isActive"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// PBXExtensionCreate is the request body for creating an extension
type PBXExtensionCreate struct {
TenantID uuid.UUID `json:"tenantId" validate:"required"`
UserID *uuid.UUID `json:"userId,omitempty"`
Extension string `json:"extension" validate:"required"`
Name string `json:"name" validate:"required"`
ExtensionType string `json:"extensionType,omitempty"`
CallerIDName string `json:"callerIdName,omitempty"`
CallerIDNumber string `json:"callerIdNumber,omitempty"`
OutboundCallerIDNumber string `json:"outboundCallerIdNumber,omitempty"`
SIPUsername string `json:"sipUsername,omitempty"`
SIPPassword string `json:"sipPassword,omitempty"`
Transport string `json:"transport,omitempty"`
Codecs []string `json:"codecs,omitempty"`
VoicemailEnabled bool `json:"voicemailEnabled"`
}
// PBXExtensionUpdate is the request body for updating an extension
type PBXExtensionUpdate struct {
Name *string `json:"name,omitempty"`
CallerIDName *string `json:"callerIdName,omitempty"`
CallerIDNumber *string `json:"callerIdNumber,omitempty"`
OutboundCallerIDNumber *string `json:"outboundCallerIdNumber,omitempty"`
SIPPassword *string `json:"sipPassword,omitempty"`
Codecs []string `json:"codecs,omitempty"`
VoicemailEnabled *bool `json:"voicemailEnabled,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
}
// PBXInboundRoute represents an inbound call route
type PBXInboundRoute struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Name string `json:"name"`
DIDNumber string `json:"didNumber,omitempty"`
DestinationType string `json:"destinationType"`
DestinationID *uuid.UUID `json:"destinationId,omitempty"`
DestinationData string `json:"destinationData,omitempty"`
Priority int `json:"priority"`
IsActive bool `json:"isActive"`
TrunkID *uuid.UUID `json:"trunkId,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// PBXInboundRouteCreate is the request body for creating an inbound route
type PBXInboundRouteCreate struct {
TenantID uuid.UUID `json:"tenantId" validate:"required"`
Name string `json:"name" validate:"required"`
DIDNumber string `json:"didNumber,omitempty"`
DestinationType string `json:"destinationType" validate:"required"`
DestinationID *uuid.UUID `json:"destinationId,omitempty"`
DestinationData string `json:"destinationData,omitempty"`
Priority int `json:"priority,omitempty"`
TrunkID *uuid.UUID `json:"trunkId,omitempty"`
}
// PBXInboundRouteUpdate is the request body for updating an inbound route
type PBXInboundRouteUpdate struct {
Name *string `json:"name,omitempty"`
DIDNumber *string `json:"didNumber,omitempty"`
DestinationType *string `json:"destinationType,omitempty"`
DestinationID *uuid.UUID `json:"destinationId,omitempty"`
DestinationData *string `json:"destinationData,omitempty"`
Priority *int `json:"priority,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
TrunkID *uuid.UUID `json:"trunkId,omitempty"`
}
// PBXOutboundRoute represents an outbound call route
type PBXOutboundRoute struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Name string `json:"name"`
Priority int `json:"priority"`
DialPatterns []string `json:"dialPatterns"`
Prepend string `json:"prepend,omitempty"`
Prefix string `json:"prefix,omitempty"`
StripDigits int `json:"stripDigits"`
OverrideCallerID bool `json:"overrideCallerId"`
CallerIDName string `json:"callerIdName,omitempty"`
CallerIDNumber string `json:"callerIdNumber,omitempty"`
IsEmergency bool `json:"isEmergency"`
IsActive bool `json:"isActive"`
Trunks []PBXOutboundRouteTrunk `json:"trunks,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// PBXOutboundRouteTrunk represents a trunk priority in an outbound route
type PBXOutboundRouteTrunk struct {
TrunkID uuid.UUID `json:"trunkId"`
TrunkName string `json:"trunkName,omitempty"`
Priority int `json:"priority"`
}
// PBXOutboundRouteCreate is the request body for creating an outbound route
type PBXOutboundRouteCreate struct {
TenantID uuid.UUID `json:"tenantId" validate:"required"`
Name string `json:"name" validate:"required"`
Priority int `json:"priority,omitempty"`
DialPatterns []string `json:"dialPatterns" validate:"required"`
Prepend string `json:"prepend,omitempty"`
Prefix string `json:"prefix,omitempty"`
StripDigits int `json:"stripDigits,omitempty"`
OverrideCallerID bool `json:"overrideCallerId"`
CallerIDName string `json:"callerIdName,omitempty"`
CallerIDNumber string `json:"callerIdNumber,omitempty"`
IsEmergency bool `json:"isEmergency"`
Trunks []PBXOutboundRouteTrunk `json:"trunks,omitempty"`
}
// PBXOutboundRouteUpdate is the request body for updating an outbound route
type PBXOutboundRouteUpdate struct {
Name *string `json:"name,omitempty"`
Priority *int `json:"priority,omitempty"`
DialPatterns []string `json:"dialPatterns,omitempty"`
Prepend *string `json:"prepend,omitempty"`
Prefix *string `json:"prefix,omitempty"`
StripDigits *int `json:"stripDigits,omitempty"`
OverrideCallerID *bool `json:"overrideCallerId,omitempty"`
CallerIDName *string `json:"callerIdName,omitempty"`
CallerIDNumber *string `json:"callerIdNumber,omitempty"`
IsEmergency *bool `json:"isEmergency,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
Trunks []PBXOutboundRouteTrunk `json:"trunks,omitempty"`
}
// PBXStatus represents the overall PBX system status
type PBXStatus struct {
AsteriskServers []PBXServerStatus `json:"asteriskServers"`
KamailioServers []PBXServerStatus `json:"kamailioServers"`
ActiveTrunks int `json:"activeTrunks"`
ActiveExtensions int `json:"activeExtensions"`
ActiveCalls int `json:"activeCalls"`
}
// PBXServerStatus represents a single PBX server status
type PBXServerStatus struct {
Host string `json:"host"`
Status string `json:"status"`
Uptime string `json:"uptime,omitempty"`
Version string `json:"version,omitempty"`
Channels int `json:"channels,omitempty"`
}
// PBXReloadResult represents the result of a PBX reload operation
type PBXReloadResult struct {
AsteriskResults []PBXReloadServer `json:"asteriskResults"`
KamailioResults []PBXReloadServer `json:"kamailioResults"`
}
// PBXReloadServer represents a reload result for a single server
type PBXReloadServer struct {
Host string `json:"host"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}

181
pkg/types/persona.go Normal file
View File

@@ -0,0 +1,181 @@
package types
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// PersonaSummary is the list view of a persona
type PersonaSummary struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Name string `json:"name"`
Archetype *string `json:"archetype,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// PersonaConfig is the full persona configuration
type PersonaConfig struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Name string `json:"name"`
Archetype *string `json:"archetype,omitempty"`
VoiceTone *string `json:"voiceTone,omitempty"`
MBTI *string `json:"mbti,omitempty"`
Openness *float64 `json:"openness,omitempty"`
Conscientiousness *float64 `json:"conscientiousness,omitempty"`
Extraversion *float64 `json:"extraversion,omitempty"`
Agreeableness *float64 `json:"agreeableness,omitempty"`
Neuroticism *float64 `json:"neuroticism,omitempty"`
PositiveRules json.RawMessage `json:"positiveRules,omitempty"`
NegativeRules json.RawMessage `json:"negativeRules,omitempty"`
Backstory *string `json:"backstory,omitempty"`
WorldBuilding *string `json:"worldBuilding,omitempty"`
GuardrailsConfig json.RawMessage `json:"guardrailsConfig,omitempty"`
TopicalRails *string `json:"topicalRails,omitempty"`
Status string `json:"status"`
DefaultModel *string `json:"defaultModel,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokensPerTurn *int `json:"maxTokensPerTurn,omitempty"`
MoralCare *float64 `json:"moralCare,omitempty"`
MoralFairness *float64 `json:"moralFairness,omitempty"`
MoralRights *float64 `json:"moralRights,omitempty"`
MoralLoyalty *float64 `json:"moralLoyalty,omitempty"`
MoralAuthority *float64 `json:"moralAuthority,omitempty"`
MoralSanctity *float64 `json:"moralSanctity,omitempty"`
IsDefault bool `json:"isDefault"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// PersonaCreate is the request body for creating a persona
type PersonaCreate struct {
TenantID uuid.UUID `json:"tenantId" validate:"required"`
Name string `json:"name" validate:"required"`
Archetype *string `json:"archetype,omitempty"`
VoiceTone *string `json:"voiceTone,omitempty"`
MBTI *string `json:"mbti,omitempty"`
Openness *float64 `json:"openness,omitempty"`
Conscientiousness *float64 `json:"conscientiousness,omitempty"`
Extraversion *float64 `json:"extraversion,omitempty"`
Agreeableness *float64 `json:"agreeableness,omitempty"`
Neuroticism *float64 `json:"neuroticism,omitempty"`
PositiveRules json.RawMessage `json:"positiveRules,omitempty"`
NegativeRules json.RawMessage `json:"negativeRules,omitempty"`
Backstory *string `json:"backstory,omitempty"`
WorldBuilding *string `json:"worldBuilding,omitempty"`
GuardrailsConfig json.RawMessage `json:"guardrailsConfig,omitempty"`
TopicalRails *string `json:"topicalRails,omitempty"`
DefaultModel *string `json:"defaultModel,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokensPerTurn *int `json:"maxTokensPerTurn,omitempty"`
MoralCare *float64 `json:"moralCare,omitempty"`
MoralFairness *float64 `json:"moralFairness,omitempty"`
MoralRights *float64 `json:"moralRights,omitempty"`
MoralLoyalty *float64 `json:"moralLoyalty,omitempty"`
MoralAuthority *float64 `json:"moralAuthority,omitempty"`
MoralSanctity *float64 `json:"moralSanctity,omitempty"`
}
// PersonaUpdate is the request body for updating a persona
type PersonaUpdate struct {
Name *string `json:"name,omitempty"`
Archetype *string `json:"archetype,omitempty"`
VoiceTone *string `json:"voiceTone,omitempty"`
MBTI *string `json:"mbti,omitempty"`
Openness *float64 `json:"openness,omitempty"`
Conscientiousness *float64 `json:"conscientiousness,omitempty"`
Extraversion *float64 `json:"extraversion,omitempty"`
Agreeableness *float64 `json:"agreeableness,omitempty"`
Neuroticism *float64 `json:"neuroticism,omitempty"`
PositiveRules json.RawMessage `json:"positiveRules,omitempty"`
NegativeRules json.RawMessage `json:"negativeRules,omitempty"`
Backstory *string `json:"backstory,omitempty"`
WorldBuilding *string `json:"worldBuilding,omitempty"`
GuardrailsConfig json.RawMessage `json:"guardrailsConfig,omitempty"`
TopicalRails *string `json:"topicalRails,omitempty"`
Status *string `json:"status,omitempty"`
DefaultModel *string `json:"defaultModel,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokensPerTurn *int `json:"maxTokensPerTurn,omitempty"`
MoralCare *float64 `json:"moralCare,omitempty"`
MoralFairness *float64 `json:"moralFairness,omitempty"`
MoralRights *float64 `json:"moralRights,omitempty"`
MoralLoyalty *float64 `json:"moralLoyalty,omitempty"`
MoralAuthority *float64 `json:"moralAuthority,omitempty"`
MoralSanctity *float64 `json:"moralSanctity,omitempty"`
IsDefault *bool `json:"isDefault,omitempty"`
}
// SelfModelSnapshot contains identity constraints, commitments, and conscience standards
type SelfModelSnapshot struct {
IdentityConstraints []IdentityConstraint `json:"identityConstraints"`
Commitments []PersonaCommitment `json:"commitments"`
ConscienceStandards []ConscienceStandard `json:"conscienceStandards"`
}
// IdentityConstraint defines a persona's behavioral boundary
type IdentityConstraint struct {
ConstraintType string `json:"type"`
ConstraintText string `json:"text"`
Description *string `json:"description,omitempty"`
Source *string `json:"source,omitempty"`
Strength float64 `json:"strength"`
}
// PersonaCommitment defines a persona's commitment
type PersonaCommitment struct {
CommitmentText string `json:"text"`
CommitmentType *string `json:"type,omitempty"`
Source *string `json:"source,omitempty"`
Strength float64 `json:"strength"`
}
// ConscienceStandard defines a moral standard for a persona
type ConscienceStandard struct {
StandardText string `json:"text"`
StandardType *string `json:"type,omitempty"`
MoralFoundation *string `json:"moralFoundation,omitempty"`
Strength float64 `json:"strength"`
}
// Experience represents an episodic experience
type Experience struct {
ID uuid.UUID `json:"id"`
EventSummary *string `json:"eventSummary,omitempty"`
EventType *string `json:"eventType,omitempty"`
OccurredAt *time.Time `json:"occurredAt,omitempty"`
Place *string `json:"place,omitempty"`
Actors *string `json:"actors,omitempty"`
Outcome *string `json:"outcome,omitempty"`
OutcomeDetail *string `json:"outcomeDetail,omitempty"`
EmotionalValence *float64 `json:"emotionalValence,omitempty"`
LessonLearned *string `json:"lessonLearned,omitempty"`
ImportanceScore *float64 `json:"importanceScore,omitempty"`
}
// MoralAssessment represents a moral assessment for a session
type MoralAssessment struct {
ActivatedFoundations json.RawMessage `json:"activatedFoundations,omitempty"`
AssessmentText *string `json:"assessmentText,omitempty"`
HasTension *bool `json:"hasTension,omitempty"`
TensionFoundations *string `json:"tensionFoundations,omitempty"`
ResolutionFoundation *string `json:"resolutionFoundation,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
}
// Evaluation represents a message evaluation
type Evaluation struct {
RoleFidelity *string `json:"roleFidelity,omitempty"`
VoiceConsistency *string `json:"voiceConsistency,omitempty"`
SafetyCompliance *string `json:"safetyCompliance,omitempty"`
CharacterBreak *bool `json:"characterBreak,omitempty"`
DriftScore *float64 `json:"driftScore,omitempty"`
EvaluatorModel *string `json:"evaluatorModel,omitempty"`
EvaluatedAt *time.Time `json:"evaluatedAt,omitempty"`
}

View File

@@ -0,0 +1,25 @@
package types
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// UserAgentConfig represents a user's personal AI agent configuration
type UserAgentConfig struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"userId"`
TenantID uuid.UUID `json:"tenantId"`
Config json.RawMessage `json:"config"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// UserAgentConfigUpsert is the request body for creating/updating a user agent config
type UserAgentConfigUpsert struct {
UserID uuid.UUID `json:"userId" validate:"required"`
TenantID uuid.UUID `json:"tenantId" validate:"required"`
Config json.RawMessage `json:"config" validate:"required"`
}

46
pkg/types/response.go Normal file
View File

@@ -0,0 +1,46 @@
package types
// Response is the standard API response envelope
type Response struct {
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
Error *APIError `json:"error,omitempty"`
RequestID string `json:"requestId,omitempty"`
}
// Meta contains pagination and count metadata
type Meta struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// NewDataResponse creates a successful response with data
func NewDataResponse(data interface{}, requestID string) *Response {
return &Response{
Data: data,
RequestID: requestID,
}
}
// NewPagedResponse creates a successful response with data and pagination
func NewPagedResponse(data interface{}, total int64, limit, offset int, requestID string) *Response {
return &Response{
Data: data,
Meta: &Meta{
Total: total,
Limit: limit,
Offset: offset,
},
RequestID: requestID,
}
}
// NewErrorResponse creates an error response
func NewErrorResponse(err *APIError, requestID string) *Response {
err.RequestID = requestID
return &Response{
Error: err,
RequestID: requestID,
}
}

115
pkg/types/voice_agent.go Normal file
View File

@@ -0,0 +1,115 @@
package types
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// VoiceAgentConfig represents a voice agent configuration
type VoiceAgentConfig struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
AgentID uuid.UUID `json:"agentId"`
GreetingText string `json:"greetingText"`
GoodbyeText string `json:"goodbyeText"`
VoiceID string `json:"voiceId"`
Language string `json:"language"`
STTProvider *string `json:"sttProvider,omitempty"`
STTModel *string `json:"sttModel,omitempty"`
TTSProvider *string `json:"ttsProvider,omitempty"`
TTSModel *string `json:"ttsModel,omitempty"`
MaxCallDurationSeconds int `json:"maxCallDurationSeconds"`
SilenceTimeoutSeconds int `json:"silenceTimeoutSeconds"`
BargeInEnabled bool `json:"bargeInEnabled"`
VADSensitivity string `json:"vadSensitivity"`
TransferEnabled bool `json:"transferEnabled"`
TransferNumber *string `json:"transferNumber,omitempty"`
BusinessHoursEnabled bool `json:"businessHoursEnabled"`
BusinessHours json.RawMessage `json:"businessHours,omitempty"`
AfterHoursText *string `json:"afterHoursText,omitempty"`
IsActive bool `json:"isActive"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// VoiceAgentConfigCreate is the request body for creating a voice agent config
type VoiceAgentConfigCreate struct {
TenantID uuid.UUID `json:"tenantId" validate:"required"`
AgentID uuid.UUID `json:"agentId" validate:"required"`
GreetingText string `json:"greetingText,omitempty"`
GoodbyeText string `json:"goodbyeText,omitempty"`
VoiceID string `json:"voiceId,omitempty"`
Language string `json:"language,omitempty"`
STTProvider *string `json:"sttProvider,omitempty"`
STTModel *string `json:"sttModel,omitempty"`
TTSProvider *string `json:"ttsProvider,omitempty"`
TTSModel *string `json:"ttsModel,omitempty"`
MaxCallDurationSeconds *int `json:"maxCallDurationSeconds,omitempty"`
SilenceTimeoutSeconds *int `json:"silenceTimeoutSeconds,omitempty"`
BargeInEnabled *bool `json:"bargeInEnabled,omitempty"`
VADSensitivity string `json:"vadSensitivity,omitempty"`
TransferEnabled *bool `json:"transferEnabled,omitempty"`
TransferNumber *string `json:"transferNumber,omitempty"`
BusinessHoursEnabled *bool `json:"businessHoursEnabled,omitempty"`
BusinessHours json.RawMessage `json:"businessHours,omitempty"`
AfterHoursText *string `json:"afterHoursText,omitempty"`
}
// VoiceAgentConfigUpdate is the request body for updating a voice agent config
type VoiceAgentConfigUpdate struct {
GreetingText *string `json:"greetingText,omitempty"`
GoodbyeText *string `json:"goodbyeText,omitempty"`
VoiceID *string `json:"voiceId,omitempty"`
Language *string `json:"language,omitempty"`
STTProvider *string `json:"sttProvider,omitempty"`
STTModel *string `json:"sttModel,omitempty"`
TTSProvider *string `json:"ttsProvider,omitempty"`
TTSModel *string `json:"ttsModel,omitempty"`
MaxCallDurationSeconds *int `json:"maxCallDurationSeconds,omitempty"`
SilenceTimeoutSeconds *int `json:"silenceTimeoutSeconds,omitempty"`
BargeInEnabled *bool `json:"bargeInEnabled,omitempty"`
VADSensitivity *string `json:"vadSensitivity,omitempty"`
TransferEnabled *bool `json:"transferEnabled,omitempty"`
TransferNumber *string `json:"transferNumber,omitempty"`
BusinessHoursEnabled *bool `json:"businessHoursEnabled,omitempty"`
BusinessHours json.RawMessage `json:"businessHours,omitempty"`
AfterHoursText *string `json:"afterHoursText,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
}
// VoiceSession represents a voice call session record
type VoiceSession struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
AgentID uuid.UUID `json:"agentId"`
CallerNumber *string `json:"callerNumber,omitempty"`
CalledNumber *string `json:"calledNumber,omitempty"`
AsteriskCallID *uuid.UUID `json:"asteriskCallId,omitempty"`
AgentSessionID *string `json:"agentSessionId,omitempty"`
TotalTurns int `json:"totalTurns"`
STTProvider *string `json:"sttProvider,omitempty"`
TTSProvider *string `json:"ttsProvider,omitempty"`
STTAudioSeconds *float64 `json:"sttAudioSeconds,omitempty"`
TTSCharacters *int `json:"ttsCharacters,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
EndReason *string `json:"endReason,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
Turns []VoiceSessionTurn `json:"turns,omitempty"`
}
// VoiceSessionTurn represents a single turn in a voice session
type VoiceSessionTurn struct {
ID uuid.UUID `json:"id"`
SessionID uuid.UUID `json:"sessionId"`
TurnNumber int `json:"turnNumber"`
Role string `json:"role"`
Text string `json:"text"`
STTConfidence *float64 `json:"sttConfidence,omitempty"`
AgentLatencyMs *int `json:"agentLatencyMs,omitempty"`
WasInterrupted bool `json:"wasInterrupted"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
}

105
scripts/install.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/bash
set -euo pipefail
APP_NAME="gsc-ops-api"
APP_USER="gsc-ops-api"
INSTALL_DIR="/srv/gosec/${APP_NAME}"
CONFIG_DIR="/etc/${APP_NAME}"
TLS_DIR="${CONFIG_DIR}/tls"
BIN_DIR="${INSTALL_DIR}/bin"
echo "=== Installing ${APP_NAME} ==="
# Create system user
if ! id "${APP_USER}" &>/dev/null; then
useradd -r -s /sbin/nologin -d "${INSTALL_DIR}" "${APP_USER}"
echo "Created system user: ${APP_USER}"
fi
# Create directories
mkdir -p "${BIN_DIR}" "${CONFIG_DIR}" "${TLS_DIR}"
# Copy binary
if [ -f "${INSTALL_DIR}/bin/${APP_NAME}" ]; then
echo "Binary found at ${BIN_DIR}/${APP_NAME}"
else
echo "WARNING: Binary not found. Build with 'make build' first."
fi
# Copy config if not exists
if [ ! -f "${CONFIG_DIR}/config.yaml" ]; then
cp "${INSTALL_DIR}/configs/config.yaml" "${CONFIG_DIR}/config.yaml"
echo "Config installed to ${CONFIG_DIR}/config.yaml"
else
echo "Config already exists, skipping"
fi
# Set permissions
chown -R root:${APP_USER} "${CONFIG_DIR}"
chmod 750 "${CONFIG_DIR}"
chmod 640 "${CONFIG_DIR}/config.yaml"
chmod 750 "${TLS_DIR}"
if [ -f "${CONFIG_DIR}/.infisical" ]; then
chown root:${APP_USER} "${CONFIG_DIR}/.infisical"
chmod 640 "${CONFIG_DIR}/.infisical"
fi
chown root:${APP_USER} "${BIN_DIR}"
if [ -f "${BIN_DIR}/${APP_NAME}" ]; then
chmod 750 "${BIN_DIR}/${APP_NAME}"
fi
# Install systemd service
cat > /etc/systemd/system/${APP_NAME}.service << 'SYSTEMD_EOF'
[Unit]
Description=GSC Operations API Server
Documentation=https://wiki.gosec.internal/infrastructure/gsc-ops-api
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=gsc-ops-api
Group=gsc-ops-api
ExecStart=/srv/gosec/gsc-ops-api/bin/gsc-ops-api
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=gsc-ops-api
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
ReadOnlyPaths=/etc/gsc-ops-api
ReadWritePaths=/srv/gosec/gsc-ops-api/bin
# Environment
Environment=GSC_OPS_API_CONFIG=/etc/gsc-ops-api/config.yaml
[Install]
WantedBy=multi-user.target
SYSTEMD_EOF
systemctl daemon-reload
echo "Systemd service installed"
echo ""
echo "=== Installation complete ==="
echo ""
echo "Next steps:"
echo " 1. Copy TLS certificates to ${TLS_DIR}/"
echo " 2. Copy Infisical token to ${CONFIG_DIR}/.infisical"
echo " 3. Edit config: ${CONFIG_DIR}/config.yaml"
echo " 4. Start service: systemctl enable --now ${APP_NAME}"
echo " 5. Check status: systemctl status ${APP_NAME}"
echo " 6. View logs: journalctl -u ${APP_NAME} -f"