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:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
28
Makefile
Normal 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
|
||||
13
configs/asterisk/extconfig.conf
Normal file
13
configs/asterisk/extconfig.conf
Normal 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
|
||||
180
configs/asterisk/extensions.conf
Normal file
180
configs/asterisk/extensions.conf
Normal 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()
|
||||
25
configs/asterisk/func_odbc.conf
Normal file
25
configs/asterisk/func_odbc.conf
Normal 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
11
configs/asterisk/odbc.ini
Normal 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
|
||||
75
configs/asterisk/pjsip.conf
Normal file
75
configs/asterisk/pjsip.conf
Normal 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
|
||||
30
configs/asterisk/postfix-main.cf
Normal file
30
configs/asterisk/postfix-main.cf
Normal 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
|
||||
13
configs/asterisk/res_odbc.conf
Normal file
13
configs/asterisk/res_odbc.conf
Normal 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
|
||||
23
configs/asterisk/sorcery.conf
Normal file
23
configs/asterisk/sorcery.conf
Normal 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
|
||||
45
configs/asterisk/voicemail.conf
Normal file
45
configs/asterisk/voicemail.conf
Normal 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
89
configs/config.yaml
Normal 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
38
go.mod
Normal 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
169
go.sum
Normal 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
191
internal/client/asterisk.go
Normal 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
|
||||
}
|
||||
67
internal/client/carddav.go
Normal file
67
internal/client/carddav.go
Normal 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
188
internal/client/ejbca.go
Normal 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)
|
||||
}
|
||||
162
internal/client/hockeypuck.go
Normal file
162
internal/client/hockeypuck.go
Normal 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
125
internal/client/kamailio.go
Normal 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
226
internal/client/ldap.go
Normal 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
180
internal/client/powerdns.go
Normal 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
377
internal/config/config.go
Normal 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
91
internal/database/db.go
Normal 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
330
internal/handler/carddav.go
Normal 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
140
internal/handler/certs.go
Normal 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))
|
||||
}
|
||||
126
internal/handler/db_tenants.go
Normal file
126
internal/handler/db_tenants.go
Normal 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))
|
||||
}
|
||||
126
internal/handler/db_users.go
Normal file
126
internal/handler/db_users.go
Normal 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))
|
||||
}
|
||||
167
internal/handler/dns_records.go
Normal file
167
internal/handler/dns_records.go
Normal 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))
|
||||
}
|
||||
115
internal/handler/dns_zones.go
Normal file
115
internal/handler/dns_zones.go
Normal 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))
|
||||
}
|
||||
94
internal/handler/health.go
Normal file
94
internal/handler/health.go
Normal 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)))
|
||||
}
|
||||
178
internal/handler/ldap_entities.go
Normal file
178
internal/handler/ldap_entities.go
Normal 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)
|
||||
}
|
||||
}
|
||||
168
internal/handler/ldap_groups.go
Normal file
168
internal/handler/ldap_groups.go
Normal 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))
|
||||
}
|
||||
215
internal/handler/ldap_users.go
Normal file
215
internal/handler/ldap_users.go
Normal 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
603
internal/handler/pbx.go
Normal 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
259
internal/handler/persona.go
Normal 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))
|
||||
}
|
||||
98
internal/handler/personal_agent.go
Normal file
98
internal/handler/personal_agent.go
Normal 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
100
internal/handler/pgp.go
Normal 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))
|
||||
}
|
||||
186
internal/handler/voice_agent.go
Normal file
186
internal/handler/voice_agent.go
Normal 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))
|
||||
}
|
||||
37
internal/middleware/apikey.go
Normal file
37
internal/middleware/apikey.go
Normal 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()
|
||||
}
|
||||
}
|
||||
68
internal/middleware/jwt.go
Normal file
68
internal/middleware/jwt.go
Normal 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
|
||||
}
|
||||
39
internal/middleware/logging.go
Normal file
39
internal/middleware/logging.go
Normal 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
|
||||
}
|
||||
}
|
||||
29
internal/middleware/requestid.go
Normal file
29
internal/middleware/requestid.go
Normal 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
225
internal/router/router.go
Normal 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)
|
||||
}
|
||||
}
|
||||
341
internal/schema/attributes.go
Normal file
341
internal/schema/attributes.go
Normal 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
112
internal/schema/entities.go
Normal 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"})
|
||||
}
|
||||
230
internal/schema/objectclasses.go
Normal file
230
internal/schema/objectclasses.go
Normal 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
273
internal/schema/registry.go
Normal 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
44
internal/schema/types.go
Normal 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
449
internal/service/carddav.go
Normal 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)
|
||||
}
|
||||
161
internal/service/certificate.go
Normal file
161
internal/service/certificate.go
Normal 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)
|
||||
}
|
||||
413
internal/service/database.go
Normal file
413
internal/service/database.go
Normal 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
324
internal/service/dns.go
Normal 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
648
internal/service/ldap.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
321
internal/service/ldap_entities.go
Normal file
321
internal/service/ldap_entities.go
Normal 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
1091
internal/service/pbx.go
Normal file
File diff suppressed because it is too large
Load Diff
514
internal/service/persona.go
Normal file
514
internal/service/persona.go
Normal 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
|
||||
}
|
||||
78
internal/service/personal_agent.go
Normal file
78
internal/service/personal_agent.go
Normal 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
110
internal/service/pgp.go
Normal 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
|
||||
}
|
||||
453
internal/service/voice_agent.go
Normal file
453
internal/service/voice_agent.go
Normal 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
|
||||
}
|
||||
533
migrations/001_pbx_realtime_views.sql
Normal file
533
migrations/001_pbx_realtime_views.sql
Normal 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
65
pkg/types/errors.go
Normal 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
396
pkg/types/models.go
Normal 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
283
pkg/types/pbx.go
Normal 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
181
pkg/types/persona.go
Normal 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"`
|
||||
}
|
||||
25
pkg/types/personal_agent.go
Normal file
25
pkg/types/personal_agent.go
Normal 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
46
pkg/types/response.go
Normal 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
115
pkg/types/voice_agent.go
Normal 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
105
scripts/install.sh
Executable 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"
|
||||
Reference in New Issue
Block a user