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