From 3847eb20366d0ddf15eec72a2e5ba5daa39afd79 Mon Sep 17 00:00:00 2001 From: "Claude (gsc-ops-api init)" Date: Sun, 3 May 2026 20:06:02 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20import=20=E2=80=94=20snapshot=20from?= =?UTF-8?q?=20admin=20host=20/srv/gosec/gsc-ops-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 22 + Makefile | 28 + configs/asterisk/extconfig.conf | 13 + configs/asterisk/extensions.conf | 180 ++++ configs/asterisk/func_odbc.conf | 25 + configs/asterisk/odbc.ini | 11 + configs/asterisk/pjsip.conf | 75 ++ configs/asterisk/postfix-main.cf | 30 + configs/asterisk/res_odbc.conf | 13 + configs/asterisk/sorcery.conf | 23 + configs/asterisk/voicemail.conf | 45 + configs/config.yaml | 89 ++ go.mod | 38 + go.sum | 169 ++++ internal/client/asterisk.go | 191 +++++ internal/client/carddav.go | 67 ++ internal/client/ejbca.go | 188 +++++ internal/client/hockeypuck.go | 162 ++++ internal/client/kamailio.go | 125 +++ internal/client/ldap.go | 226 +++++ internal/client/powerdns.go | 180 ++++ internal/config/config.go | 377 +++++++++ internal/database/db.go | 91 +++ internal/handler/carddav.go | 330 ++++++++ internal/handler/certs.go | 140 ++++ internal/handler/db_tenants.go | 126 +++ internal/handler/db_users.go | 126 +++ internal/handler/dns_records.go | 167 ++++ internal/handler/dns_zones.go | 115 +++ internal/handler/health.go | 94 +++ internal/handler/ldap_entities.go | 178 ++++ internal/handler/ldap_groups.go | 168 ++++ internal/handler/ldap_users.go | 215 +++++ internal/handler/pbx.go | 603 ++++++++++++++ internal/handler/persona.go | 259 ++++++ internal/handler/personal_agent.go | 98 +++ internal/handler/pgp.go | 100 +++ internal/handler/voice_agent.go | 186 +++++ internal/middleware/apikey.go | 37 + internal/middleware/jwt.go | 68 ++ internal/middleware/logging.go | 39 + internal/middleware/requestid.go | 29 + internal/router/router.go | 225 +++++ internal/schema/attributes.go | 341 ++++++++ internal/schema/entities.go | 112 +++ internal/schema/objectclasses.go | 230 ++++++ internal/schema/registry.go | 273 +++++++ internal/schema/types.go | 44 + internal/service/carddav.go | 449 ++++++++++ internal/service/certificate.go | 161 ++++ internal/service/database.go | 413 ++++++++++ internal/service/dns.go | 324 ++++++++ internal/service/ldap.go | 648 +++++++++++++++ internal/service/ldap_entities.go | 321 ++++++++ internal/service/pbx.go | 1091 +++++++++++++++++++++++++ internal/service/persona.go | 514 ++++++++++++ internal/service/personal_agent.go | 78 ++ internal/service/pgp.go | 110 +++ internal/service/voice_agent.go | 453 ++++++++++ migrations/001_pbx_realtime_views.sql | 533 ++++++++++++ pkg/types/errors.go | 65 ++ pkg/types/models.go | 396 +++++++++ pkg/types/pbx.go | 283 +++++++ pkg/types/persona.go | 181 ++++ pkg/types/personal_agent.go | 25 + pkg/types/response.go | 46 ++ pkg/types/voice_agent.go | 115 +++ scripts/install.sh | 105 +++ 68 files changed, 12982 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 configs/asterisk/extconfig.conf create mode 100644 configs/asterisk/extensions.conf create mode 100644 configs/asterisk/func_odbc.conf create mode 100644 configs/asterisk/odbc.ini create mode 100644 configs/asterisk/pjsip.conf create mode 100644 configs/asterisk/postfix-main.cf create mode 100644 configs/asterisk/res_odbc.conf create mode 100644 configs/asterisk/sorcery.conf create mode 100644 configs/asterisk/voicemail.conf create mode 100644 configs/config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/asterisk.go create mode 100644 internal/client/carddav.go create mode 100644 internal/client/ejbca.go create mode 100644 internal/client/hockeypuck.go create mode 100644 internal/client/kamailio.go create mode 100644 internal/client/ldap.go create mode 100644 internal/client/powerdns.go create mode 100644 internal/config/config.go create mode 100644 internal/database/db.go create mode 100644 internal/handler/carddav.go create mode 100644 internal/handler/certs.go create mode 100644 internal/handler/db_tenants.go create mode 100644 internal/handler/db_users.go create mode 100644 internal/handler/dns_records.go create mode 100644 internal/handler/dns_zones.go create mode 100644 internal/handler/health.go create mode 100644 internal/handler/ldap_entities.go create mode 100644 internal/handler/ldap_groups.go create mode 100644 internal/handler/ldap_users.go create mode 100644 internal/handler/pbx.go create mode 100644 internal/handler/persona.go create mode 100644 internal/handler/personal_agent.go create mode 100644 internal/handler/pgp.go create mode 100644 internal/handler/voice_agent.go create mode 100644 internal/middleware/apikey.go create mode 100644 internal/middleware/jwt.go create mode 100644 internal/middleware/logging.go create mode 100644 internal/middleware/requestid.go create mode 100644 internal/router/router.go create mode 100644 internal/schema/attributes.go create mode 100644 internal/schema/entities.go create mode 100644 internal/schema/objectclasses.go create mode 100644 internal/schema/registry.go create mode 100644 internal/schema/types.go create mode 100644 internal/service/carddav.go create mode 100644 internal/service/certificate.go create mode 100644 internal/service/database.go create mode 100644 internal/service/dns.go create mode 100644 internal/service/ldap.go create mode 100644 internal/service/ldap_entities.go create mode 100644 internal/service/pbx.go create mode 100644 internal/service/persona.go create mode 100644 internal/service/personal_agent.go create mode 100644 internal/service/pgp.go create mode 100644 internal/service/voice_agent.go create mode 100644 migrations/001_pbx_realtime_views.sql create mode 100644 pkg/types/errors.go create mode 100644 pkg/types/models.go create mode 100644 pkg/types/pbx.go create mode 100644 pkg/types/persona.go create mode 100644 pkg/types/personal_agent.go create mode 100644 pkg/types/response.go create mode 100644 pkg/types/voice_agent.go create mode 100755 scripts/install.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02ae518 --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..94c4c03 --- /dev/null +++ b/Makefile @@ -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 diff --git a/configs/asterisk/extconfig.conf b/configs/asterisk/extconfig.conf new file mode 100644 index 0000000..344b67e --- /dev/null +++ b/configs/asterisk/extconfig.conf @@ -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 diff --git a/configs/asterisk/extensions.conf b/configs/asterisk/extensions.conf new file mode 100644 index 0000000..0aeed5c --- /dev/null +++ b/configs/asterisk/extensions.conf @@ -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() diff --git a/configs/asterisk/func_odbc.conf b/configs/asterisk/func_odbc.conf new file mode 100644 index 0000000..430277d --- /dev/null +++ b/configs/asterisk/func_odbc.conf @@ -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 diff --git a/configs/asterisk/odbc.ini b/configs/asterisk/odbc.ini new file mode 100644 index 0000000..079821b --- /dev/null +++ b/configs/asterisk/odbc.ini @@ -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 diff --git a/configs/asterisk/pjsip.conf b/configs/asterisk/pjsip.conf new file mode 100644 index 0000000..f0696f5 --- /dev/null +++ b/configs/asterisk/pjsip.conf @@ -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 diff --git a/configs/asterisk/postfix-main.cf b/configs/asterisk/postfix-main.cf new file mode 100644 index 0000000..8bf18dd --- /dev/null +++ b/configs/asterisk/postfix-main.cf @@ -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 diff --git a/configs/asterisk/res_odbc.conf b/configs/asterisk/res_odbc.conf new file mode 100644 index 0000000..0d39feb --- /dev/null +++ b/configs/asterisk/res_odbc.conf @@ -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 diff --git a/configs/asterisk/sorcery.conf b/configs/asterisk/sorcery.conf new file mode 100644 index 0000000..1ee07dc --- /dev/null +++ b/configs/asterisk/sorcery.conf @@ -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 diff --git a/configs/asterisk/voicemail.conf b/configs/asterisk/voicemail.conf new file mode 100644 index 0000000..d663ce3 --- /dev/null +++ b/configs/asterisk/voicemail.conf @@ -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] diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..a17ce42 --- /dev/null +++ b/configs/config.yaml @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b6959b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c462a4d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/client/asterisk.go b/internal/client/asterisk.go new file mode 100644 index 0000000..2e1cd6e --- /dev/null +++ b/internal/client/asterisk.go @@ -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 +} diff --git a/internal/client/carddav.go b/internal/client/carddav.go new file mode 100644 index 0000000..0bef14b --- /dev/null +++ b/internal/client/carddav.go @@ -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() + } +} diff --git a/internal/client/ejbca.go b/internal/client/ejbca.go new file mode 100644 index 0000000..9a03b10 --- /dev/null +++ b/internal/client/ejbca.go @@ -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) +} diff --git a/internal/client/hockeypuck.go b/internal/client/hockeypuck.go new file mode 100644 index 0000000..2600820 --- /dev/null +++ b/internal/client/hockeypuck.go @@ -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") +} diff --git a/internal/client/kamailio.go b/internal/client/kamailio.go new file mode 100644 index 0000000..a39811f --- /dev/null +++ b/internal/client/kamailio.go @@ -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 +} diff --git a/internal/client/ldap.go b/internal/client/ldap.go new file mode 100644 index 0000000..7a391fe --- /dev/null +++ b/internal/client/ldap.go @@ -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 +} diff --git a/internal/client/powerdns.go b/internal/client/powerdns.go new file mode 100644 index 0000000..aa3d79f --- /dev/null +++ b/internal/client/powerdns.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9f74650 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..1786ac4 --- /dev/null +++ b/internal/database/db.go @@ -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() +} diff --git a/internal/handler/carddav.go b/internal/handler/carddav.go new file mode 100644 index 0000000..18cda19 --- /dev/null +++ b/internal/handler/carddav.go @@ -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)) +} diff --git a/internal/handler/certs.go b/internal/handler/certs.go new file mode 100644 index 0000000..604756e --- /dev/null +++ b/internal/handler/certs.go @@ -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)) +} diff --git a/internal/handler/db_tenants.go b/internal/handler/db_tenants.go new file mode 100644 index 0000000..0a5b0c3 --- /dev/null +++ b/internal/handler/db_tenants.go @@ -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)) +} diff --git a/internal/handler/db_users.go b/internal/handler/db_users.go new file mode 100644 index 0000000..9b03e4b --- /dev/null +++ b/internal/handler/db_users.go @@ -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)) +} diff --git a/internal/handler/dns_records.go b/internal/handler/dns_records.go new file mode 100644 index 0000000..f1bd261 --- /dev/null +++ b/internal/handler/dns_records.go @@ -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)) +} diff --git a/internal/handler/dns_zones.go b/internal/handler/dns_zones.go new file mode 100644 index 0000000..8045d55 --- /dev/null +++ b/internal/handler/dns_zones.go @@ -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)) +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..1fe1cad --- /dev/null +++ b/internal/handler/health.go @@ -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))) +} diff --git a/internal/handler/ldap_entities.go b/internal/handler/ldap_entities.go new file mode 100644 index 0000000..ad7ac93 --- /dev/null +++ b/internal/handler/ldap_entities.go @@ -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) + } +} diff --git a/internal/handler/ldap_groups.go b/internal/handler/ldap_groups.go new file mode 100644 index 0000000..eebf68b --- /dev/null +++ b/internal/handler/ldap_groups.go @@ -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)) +} diff --git a/internal/handler/ldap_users.go b/internal/handler/ldap_users.go new file mode 100644 index 0000000..10f41c3 --- /dev/null +++ b/internal/handler/ldap_users.go @@ -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.: 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)) +} diff --git a/internal/handler/pbx.go b/internal/handler/pbx.go new file mode 100644 index 0000000..9c49396 --- /dev/null +++ b/internal/handler/pbx.go @@ -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)) +} diff --git a/internal/handler/persona.go b/internal/handler/persona.go new file mode 100644 index 0000000..1339982 --- /dev/null +++ b/internal/handler/persona.go @@ -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)) +} diff --git a/internal/handler/personal_agent.go b/internal/handler/personal_agent.go new file mode 100644 index 0000000..91a1d47 --- /dev/null +++ b/internal/handler/personal_agent.go @@ -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) +} diff --git a/internal/handler/pgp.go b/internal/handler/pgp.go new file mode 100644 index 0000000..88a195c --- /dev/null +++ b/internal/handler/pgp.go @@ -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)) +} diff --git a/internal/handler/voice_agent.go b/internal/handler/voice_agent.go new file mode 100644 index 0000000..39096bb --- /dev/null +++ b/internal/handler/voice_agent.go @@ -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)) +} diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go new file mode 100644 index 0000000..584b3ff --- /dev/null +++ b/internal/middleware/apikey.go @@ -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() + } +} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go new file mode 100644 index 0000000..760cfe0 --- /dev/null +++ b/internal/middleware/jwt.go @@ -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 +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..06aa628 --- /dev/null +++ b/internal/middleware/logging.go @@ -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 + } +} diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go new file mode 100644 index 0000000..49f4f08 --- /dev/null +++ b/internal/middleware/requestid.go @@ -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 "" +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..29c0d4d --- /dev/null +++ b/internal/router/router.go @@ -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) + } +} diff --git a/internal/schema/attributes.go b/internal/schema/attributes.go new file mode 100644 index 0000000..adc3e8e --- /dev/null +++ b/internal/schema/attributes.go @@ -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) +} diff --git a/internal/schema/entities.go b/internal/schema/entities.go new file mode 100644 index 0000000..dd1d5a6 --- /dev/null +++ b/internal/schema/entities.go @@ -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"}) +} diff --git a/internal/schema/objectclasses.go b/internal/schema/objectclasses.go new file mode 100644 index 0000000..6e66def --- /dev/null +++ b/internal/schema/objectclasses.go @@ -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") +} diff --git a/internal/schema/registry.go b/internal/schema/registry.go new file mode 100644 index 0000000..ec5eaf7 --- /dev/null +++ b/internal/schema/registry.go @@ -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 +} diff --git a/internal/schema/types.go b/internal/schema/types.go new file mode 100644 index 0000000..9d490be --- /dev/null +++ b/internal/schema/types.go @@ -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 +} diff --git a/internal/service/carddav.go b/internal/service/carddav.go new file mode 100644 index 0000000..afba468 --- /dev/null +++ b/internal/service/carddav.go @@ -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) +} diff --git a/internal/service/certificate.go b/internal/service/certificate.go new file mode 100644 index 0000000..f9cc208 --- /dev/null +++ b/internal/service/certificate.go @@ -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) +} diff --git a/internal/service/database.go b/internal/service/database.go new file mode 100644 index 0000000..c1005ed --- /dev/null +++ b/internal/service/database.go @@ -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 +} diff --git a/internal/service/dns.go b/internal/service/dns.go new file mode 100644 index 0000000..649fa5b --- /dev/null +++ b/internal/service/dns.go @@ -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 +} diff --git a/internal/service/ldap.go b/internal/service/ldap.go new file mode 100644 index 0000000..aef5ab2 --- /dev/null +++ b/internal/service/ldap.go @@ -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"), + } +} diff --git a/internal/service/ldap_entities.go b/internal/service/ldap_entities.go new file mode 100644 index 0000000..7ad18b5 --- /dev/null +++ b/internal/service/ldap_entities.go @@ -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 +} diff --git a/internal/service/pbx.go b/internal/service/pbx.go new file mode 100644 index 0000000..8c88630 --- /dev/null +++ b/internal/service/pbx.go @@ -0,0 +1,1091 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog" + + "github.com/gosec/gsc-ops-api/internal/client" + "github.com/gosec/gsc-ops-api/pkg/types" +) + +// PBXService handles PBX trunk, DID, extension, and route operations +type PBXService struct { + pool *pgxpool.Pool // gsc_admin database + asterisk *client.AsteriskClient + kamailio *client.KamailioClient + logger zerolog.Logger +} + +// NewPBXService creates a new PBX service +func NewPBXService(pool *pgxpool.Pool, asterisk *client.AsteriskClient, kamailio *client.KamailioClient, logger zerolog.Logger) *PBXService { + return &PBXService{ + pool: pool, + asterisk: asterisk, + kamailio: kamailio, + logger: logger.With().Str("service", "pbx").Logger(), + } +} + +// ============================================================================ +// Trunks +// ============================================================================ + +// ListTrunks lists SIP trunks with optional filters +func (s *PBXService) ListTrunks(ctx context.Context, params types.ListParams) ([]types.PBXTrunk, int64, error) { + params = types.DefaultListParams(params) + + countQuery := `SELECT COUNT(*) FROM pbx_trunks WHERE 1=1` + listQuery := `SELECT t.id, t.tenant_id, t.provider_id, t.name, COALESCE(t.description,''), + t.trunk_type, t.status, + t.host, t.port, t.transport, COALESCE(t.username,''), COALESCE(t.auth_realm,''), + COALESCE(t.from_domain,''), COALESCE(t.from_user,''), + t.register, t.codecs, t.dtmf_mode, t.nat_mode, + t.max_channels, t.current_channels, + COALESCE(t.outbound_caller_id_name,''), COALESCE(t.outbound_caller_id_number,''), + t.priority, + t.created_at, t.updated_at, + (SELECT COUNT(*) FROM pbx_trunk_dids d WHERE d.trunk_id = t.id) AS did_count + FROM pbx_trunks t WHERE 1=1` + + args := []interface{}{} + argIdx := 1 + + if params.Status != "" { + countQuery += fmt.Sprintf(" AND status = $%d", argIdx) + listQuery += fmt.Sprintf(" AND t.status = $%d", argIdx) + args = append(args, params.Status) + argIdx++ + } + if params.Search != "" { + countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR host ILIKE $%d OR username ILIKE $%d)", argIdx, argIdx, argIdx) + listQuery += fmt.Sprintf(" AND (t.name ILIKE $%d OR t.host ILIKE $%d OR t.username 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 t.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() + + trunks := make([]types.PBXTrunk, 0) + for rows.Next() { + var t types.PBXTrunk + if err := rows.Scan(&t.ID, &t.TenantID, &t.ProviderID, &t.Name, &t.Description, + &t.TrunkType, &t.Status, + &t.Host, &t.Port, &t.Transport, &t.Username, &t.AuthRealm, + &t.FromDomain, &t.FromUser, + &t.Register, &t.Codecs, &t.DtmfMode, &t.NatMode, + &t.MaxChannels, &t.CurrentChannels, + &t.OutboundCallerIDName, &t.OutboundCallerIDNum, + &t.Priority, + &t.CreatedAt, &t.UpdatedAt, &t.DIDCount); err != nil { + return nil, 0, fmt.Errorf("scan failed: %w", err) + } + trunks = append(trunks, t) + } + + return trunks, total, nil +} + +// GetTrunk gets a trunk by ID +func (s *PBXService) GetTrunk(ctx context.Context, id uuid.UUID) (*types.PBXTrunk, error) { + var t types.PBXTrunk + err := s.pool.QueryRow(ctx, + `SELECT t.id, t.tenant_id, t.provider_id, t.name, COALESCE(t.description,''), + t.trunk_type, t.status, + t.host, t.port, t.transport, COALESCE(t.username,''), COALESCE(t.auth_realm,''), + COALESCE(t.from_domain,''), COALESCE(t.from_user,''), + t.register, t.codecs, t.dtmf_mode, t.nat_mode, + t.max_channels, t.current_channels, + COALESCE(t.outbound_caller_id_name,''), COALESCE(t.outbound_caller_id_number,''), + t.priority, + t.created_at, t.updated_at, + (SELECT COUNT(*) FROM pbx_trunk_dids d WHERE d.trunk_id = t.id) AS did_count + FROM pbx_trunks t WHERE t.id = $1`, id). + Scan(&t.ID, &t.TenantID, &t.ProviderID, &t.Name, &t.Description, + &t.TrunkType, &t.Status, + &t.Host, &t.Port, &t.Transport, &t.Username, &t.AuthRealm, + &t.FromDomain, &t.FromUser, + &t.Register, &t.Codecs, &t.DtmfMode, &t.NatMode, + &t.MaxChannels, &t.CurrentChannels, + &t.OutboundCallerIDName, &t.OutboundCallerIDNum, + &t.Priority, + &t.CreatedAt, &t.UpdatedAt, &t.DIDCount) + if err != nil { + return nil, err + } + return &t, nil +} + +// CreateTrunk creates a new SIP trunk +func (s *PBXService) CreateTrunk(ctx context.Context, req *types.PBXTrunkCreate) (*types.PBXTrunk, error) { + id := uuid.New() + now := time.Now().UTC() + + port := req.Port + if port == 0 { + port = 5060 + } + transport := req.Transport + if transport == "" { + transport = "udp" + } + codecs := req.Codecs + if len(codecs) == 0 { + codecs = []string{"ulaw", "alaw"} + } + dtmfMode := req.DtmfMode + if dtmfMode == "" { + dtmfMode = "rfc4733" + } + + natMode := req.NatMode + if natMode == "" { + natMode = "yes" + } + + _, err := s.pool.Exec(ctx, + `INSERT INTO pbx_trunks (id, tenant_id, provider_id, name, description, trunk_type, status, + host, port, transport, username, auth_realm, from_domain, from_user, + register, codecs, dtmf_mode, nat_mode, max_channels, + outbound_caller_id_name, outbound_caller_id_number, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pjsip', 'inactive', + $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, + $18, $19, + $20, $20)`, + id, req.TenantID, req.ProviderID, req.Name, nilIfEmpty(req.Description), + req.Host, port, transport, nilIfEmpty(req.Username), nilIfEmpty(req.AuthRealm), + nilIfEmpty(req.FromDomain), nilIfEmpty(req.FromUser), + req.Register, codecs, dtmfMode, natMode, req.MaxChannels, + nilIfEmpty(req.OutboundCallerIDName), nilIfEmpty(req.OutboundCallerIDNum), + now) + if err != nil { + return nil, fmt.Errorf("insert failed: %w", err) + } + + // If password provided, store in sip_passwords via asterisk DB + // (handled by caller or separate sync endpoint) + + return s.GetTrunk(ctx, id) +} + +// UpdateTrunk updates a trunk +func (s *PBXService) UpdateTrunk(ctx context.Context, id uuid.UUID, req *types.PBXTrunkUpdate) (*types.PBXTrunk, 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.Description != nil { + setClauses = append(setClauses, fmt.Sprintf("description = $%d", argIdx)) + args = append(args, *req.Description) + argIdx++ + } + if req.Host != nil { + setClauses = append(setClauses, fmt.Sprintf("host = $%d", argIdx)) + args = append(args, *req.Host) + argIdx++ + } + if req.Port != nil { + setClauses = append(setClauses, fmt.Sprintf("port = $%d", argIdx)) + args = append(args, *req.Port) + argIdx++ + } + if req.Transport != nil { + setClauses = append(setClauses, fmt.Sprintf("transport = $%d", argIdx)) + args = append(args, *req.Transport) + argIdx++ + } + if req.Username != nil { + setClauses = append(setClauses, fmt.Sprintf("username = $%d", argIdx)) + args = append(args, *req.Username) + argIdx++ + } + if req.AuthRealm != nil { + setClauses = append(setClauses, fmt.Sprintf("auth_realm = $%d", argIdx)) + args = append(args, *req.AuthRealm) + argIdx++ + } + if req.FromDomain != nil { + setClauses = append(setClauses, fmt.Sprintf("from_domain = $%d", argIdx)) + args = append(args, *req.FromDomain) + argIdx++ + } + if req.FromUser != nil { + setClauses = append(setClauses, fmt.Sprintf("from_user = $%d", argIdx)) + args = append(args, *req.FromUser) + argIdx++ + } + if req.Register != nil { + setClauses = append(setClauses, fmt.Sprintf("register = $%d", argIdx)) + args = append(args, *req.Register) + argIdx++ + } + if req.Codecs != nil { + setClauses = append(setClauses, fmt.Sprintf("codecs = $%d", argIdx)) + args = append(args, req.Codecs) + argIdx++ + } + if req.DtmfMode != nil { + setClauses = append(setClauses, fmt.Sprintf("dtmf_mode = $%d", argIdx)) + args = append(args, *req.DtmfMode) + argIdx++ + } + if req.NatMode != nil { + setClauses = append(setClauses, fmt.Sprintf("nat_mode = $%d", argIdx)) + args = append(args, *req.NatMode) + argIdx++ + } + if req.MaxChannels != nil { + setClauses = append(setClauses, fmt.Sprintf("max_channels = $%d", argIdx)) + args = append(args, *req.MaxChannels) + argIdx++ + } + if req.OutboundCallerIDName != nil { + setClauses = append(setClauses, fmt.Sprintf("outbound_caller_id_name = $%d", argIdx)) + args = append(args, *req.OutboundCallerIDName) + argIdx++ + } + if req.OutboundCallerIDNum != nil { + setClauses = append(setClauses, fmt.Sprintf("outbound_caller_id_number = $%d", argIdx)) + args = append(args, *req.OutboundCallerIDNum) + argIdx++ + } + if req.Status != nil { + setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx)) + args = append(args, *req.Status) + argIdx++ + } + + if len(setClauses) == 0 { + return s.GetTrunk(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 pbx_trunks 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.GetTrunk(ctx, id) +} + +// DeleteTrunk soft-deletes a trunk by setting status to inactive +func (s *PBXService) DeleteTrunk(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, + `UPDATE pbx_trunks SET status = 'inactive', updated_at = $1 WHERE id = $2`, + time.Now().UTC(), id) + if err != nil { + return err + } + + // Reload Asterisk and Kamailio to remove the trunk + if s.asterisk != nil { + go s.asterisk.ReloadPJSIP() + } + if s.kamailio != nil { + go s.kamailio.ReloadDispatcher() + } + + return nil +} + +// ActivateTrunk sets trunk status to active and reloads Asterisk + Kamailio +func (s *PBXService) ActivateTrunk(ctx context.Context, id uuid.UUID) (*types.PBXTrunk, error) { + _, err := s.pool.Exec(ctx, + `UPDATE pbx_trunks SET status = 'active', updated_at = $1 WHERE id = $2`, + time.Now().UTC(), id) + if err != nil { + return nil, fmt.Errorf("activate failed: %w", err) + } + + // Reload Asterisk PJSIP to pick up new trunk + if s.asterisk != nil { + go s.asterisk.ReloadPJSIP() + } + + // Reload Kamailio dispatcher in case routing changed + if s.kamailio != nil { + go s.kamailio.ReloadDispatcher() + } + + return s.GetTrunk(ctx, id) +} + +// DeactivateTrunk sets trunk status to inactive and reloads Asterisk + Kamailio +func (s *PBXService) DeactivateTrunk(ctx context.Context, id uuid.UUID) (*types.PBXTrunk, error) { + _, err := s.pool.Exec(ctx, + `UPDATE pbx_trunks SET status = 'inactive', updated_at = $1 WHERE id = $2`, + time.Now().UTC(), id) + if err != nil { + return nil, fmt.Errorf("deactivate failed: %w", err) + } + + // Reload Asterisk + if s.asterisk != nil { + go s.asterisk.ReloadPJSIP() + } + + // Reload Kamailio dispatcher + if s.kamailio != nil { + go s.kamailio.ReloadDispatcher() + } + + return s.GetTrunk(ctx, id) +} + +// ============================================================================ +// Trunk DIDs +// ============================================================================ + +// ListTrunkDIDs lists DIDs for a trunk +func (s *PBXService) ListTrunkDIDs(ctx context.Context, trunkID uuid.UUID) ([]types.PBXTrunkDID, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, trunk_id, tenant_id, did_number, description, + destination_type, destination_id, is_active, created_at + FROM pbx_trunk_dids WHERE trunk_id = $1 ORDER BY did_number`, trunkID) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + defer rows.Close() + + dids := make([]types.PBXTrunkDID, 0) + for rows.Next() { + var d types.PBXTrunkDID + if err := rows.Scan(&d.ID, &d.TrunkID, &d.TenantID, &d.DIDNumber, &d.Description, + &d.DestinationType, &d.DestinationID, &d.IsActive, &d.CreatedAt); err != nil { + return nil, fmt.Errorf("scan failed: %w", err) + } + dids = append(dids, d) + } + return dids, nil +} + +// CreateTrunkDID adds a DID to a trunk +func (s *PBXService) CreateTrunkDID(ctx context.Context, trunkID uuid.UUID, req *types.PBXTrunkDIDCreate) (*types.PBXTrunkDID, error) { + id := uuid.New() + now := time.Now().UTC() + + _, err := s.pool.Exec(ctx, + `INSERT INTO pbx_trunk_dids (id, trunk_id, tenant_id, did_number, description, + destination_type, destination_id, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, $8)`, + id, trunkID, req.TenantID, req.DIDNumber, nilIfEmpty(req.Description), + nilIfEmpty(req.DestinationType), req.DestinationID, now) + if err != nil { + return nil, fmt.Errorf("insert failed: %w", err) + } + + var d types.PBXTrunkDID + err = s.pool.QueryRow(ctx, + `SELECT id, trunk_id, tenant_id, did_number, description, + destination_type, destination_id, is_active, created_at + FROM pbx_trunk_dids WHERE id = $1`, id). + Scan(&d.ID, &d.TrunkID, &d.TenantID, &d.DIDNumber, &d.Description, + &d.DestinationType, &d.DestinationID, &d.IsActive, &d.CreatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +// DeleteTrunkDID removes a DID from a trunk +func (s *PBXService) DeleteTrunkDID(ctx context.Context, trunkID, didID uuid.UUID) error { + _, err := s.pool.Exec(ctx, + `DELETE FROM pbx_trunk_dids WHERE id = $1 AND trunk_id = $2`, didID, trunkID) + return err +} + +// ============================================================================ +// Extensions +// ============================================================================ + +// ListExtensions lists extensions with optional filters +func (s *PBXService) ListExtensions(ctx context.Context, params types.ListParams) ([]types.PBXExtension, int64, error) { + params = types.DefaultListParams(params) + + countQuery := `SELECT COUNT(*) FROM pbx_extensions WHERE 1=1` + listQuery := `SELECT id, tenant_id, user_id, extension, name, extension_type, + caller_id_name, caller_id_number, outbound_caller_id_number, + sip_username, transport, codecs, voicemail_enabled, is_active, + created_at, updated_at + FROM pbx_extensions 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 (extension ILIKE $%d OR name ILIKE $%d OR sip_username ILIKE $%d)", argIdx, argIdx, argIdx) + listQuery += fmt.Sprintf(" AND (extension ILIKE $%d OR name ILIKE $%d OR sip_username 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 extension ASC 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() + + exts := make([]types.PBXExtension, 0) + for rows.Next() { + var e types.PBXExtension + if err := rows.Scan(&e.ID, &e.TenantID, &e.UserID, &e.Extension, &e.Name, &e.ExtensionType, + &e.CallerIDName, &e.CallerIDNumber, &e.OutboundCallerIDNumber, + &e.SIPUsername, &e.Transport, &e.Codecs, &e.VoicemailEnabled, &e.IsActive, + &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, 0, fmt.Errorf("scan failed: %w", err) + } + exts = append(exts, e) + } + + return exts, total, nil +} + +// GetExtension gets an extension by ID +func (s *PBXService) GetExtension(ctx context.Context, id uuid.UUID) (*types.PBXExtension, error) { + var e types.PBXExtension + err := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, user_id, extension, name, extension_type, + caller_id_name, caller_id_number, outbound_caller_id_number, + sip_username, transport, codecs, voicemail_enabled, is_active, + created_at, updated_at + FROM pbx_extensions WHERE id = $1`, id). + Scan(&e.ID, &e.TenantID, &e.UserID, &e.Extension, &e.Name, &e.ExtensionType, + &e.CallerIDName, &e.CallerIDNumber, &e.OutboundCallerIDNumber, + &e.SIPUsername, &e.Transport, &e.Codecs, &e.VoicemailEnabled, &e.IsActive, + &e.CreatedAt, &e.UpdatedAt) + if err != nil { + return nil, err + } + return &e, nil +} + +// CreateExtension creates a new extension +func (s *PBXService) CreateExtension(ctx context.Context, req *types.PBXExtensionCreate) (*types.PBXExtension, error) { + id := uuid.New() + now := time.Now().UTC() + + extType := req.ExtensionType + if extType == "" { + extType = "user" + } + transport := req.Transport + if transport == "" { + transport = "udp" + } + codecs := req.Codecs + if len(codecs) == 0 { + codecs = []string{"opus", "ulaw", "alaw"} + } + sipUsername := req.SIPUsername + if sipUsername == "" { + sipUsername = req.Extension + } + + _, err := s.pool.Exec(ctx, + `INSERT INTO pbx_extensions (id, tenant_id, user_id, extension, name, extension_type, + caller_id_name, caller_id_number, outbound_caller_id_number, + sip_username, transport, codecs, voicemail_enabled, is_active, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, true, $14, $14)`, + id, req.TenantID, req.UserID, req.Extension, req.Name, extType, + nilIfEmpty(req.CallerIDName), nilIfEmpty(req.CallerIDNumber), + nilIfEmpty(req.OutboundCallerIDNumber), + sipUsername, transport, codecs, req.VoicemailEnabled, now) + if err != nil { + return nil, fmt.Errorf("insert failed: %w", err) + } + + return s.GetExtension(ctx, id) +} + +// UpdateExtension updates an extension +func (s *PBXService) UpdateExtension(ctx context.Context, id uuid.UUID, req *types.PBXExtensionUpdate) (*types.PBXExtension, 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.CallerIDName != nil { + setClauses = append(setClauses, fmt.Sprintf("caller_id_name = $%d", argIdx)) + args = append(args, *req.CallerIDName) + argIdx++ + } + if req.CallerIDNumber != nil { + setClauses = append(setClauses, fmt.Sprintf("caller_id_number = $%d", argIdx)) + args = append(args, *req.CallerIDNumber) + argIdx++ + } + if req.OutboundCallerIDNumber != nil { + setClauses = append(setClauses, fmt.Sprintf("outbound_caller_id_number = $%d", argIdx)) + args = append(args, *req.OutboundCallerIDNumber) + argIdx++ + } + if req.Codecs != nil { + setClauses = append(setClauses, fmt.Sprintf("codecs = $%d", argIdx)) + args = append(args, req.Codecs) + argIdx++ + } + if req.VoicemailEnabled != nil { + setClauses = append(setClauses, fmt.Sprintf("voicemail_enabled = $%d", argIdx)) + args = append(args, *req.VoicemailEnabled) + argIdx++ + } + if req.IsActive != nil { + setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) + args = append(args, *req.IsActive) + argIdx++ + } + + if len(setClauses) == 0 { + return s.GetExtension(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 pbx_extensions 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.GetExtension(ctx, id) +} + +// DeleteExtension soft-deletes an extension +func (s *PBXService) DeleteExtension(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, + `UPDATE pbx_extensions SET is_active = false, updated_at = $1 WHERE id = $2`, + time.Now().UTC(), id) + return err +} + +// ============================================================================ +// Inbound Routes +// ============================================================================ + +// ListInboundRoutes lists inbound routes +func (s *PBXService) ListInboundRoutes(ctx context.Context, params types.ListParams) ([]types.PBXInboundRoute, int64, error) { + params = types.DefaultListParams(params) + + countQuery := `SELECT COUNT(*) FROM pbx_inbound_routes WHERE 1=1` + listQuery := `SELECT id, tenant_id, name, did_number, destination_type, + destination_id, destination_data, priority, is_active, trunk_id, + created_at, updated_at + FROM pbx_inbound_routes WHERE 1=1` + + args := []interface{}{} + argIdx := 1 + + if params.Search != "" { + countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR did_number ILIKE $%d)", argIdx, argIdx) + listQuery += fmt.Sprintf(" AND (name ILIKE $%d OR did_number ILIKE $%d)", 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 priority ASC, 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() + + routes := make([]types.PBXInboundRoute, 0) + for rows.Next() { + var r types.PBXInboundRoute + if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.DIDNumber, &r.DestinationType, + &r.DestinationID, &r.DestinationData, &r.Priority, &r.IsActive, &r.TrunkID, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, 0, fmt.Errorf("scan failed: %w", err) + } + routes = append(routes, r) + } + + return routes, total, nil +} + +// GetInboundRoute gets an inbound route by ID +func (s *PBXService) GetInboundRoute(ctx context.Context, id uuid.UUID) (*types.PBXInboundRoute, error) { + var r types.PBXInboundRoute + err := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, name, did_number, destination_type, + destination_id, destination_data, priority, is_active, trunk_id, + created_at, updated_at + FROM pbx_inbound_routes WHERE id = $1`, id). + Scan(&r.ID, &r.TenantID, &r.Name, &r.DIDNumber, &r.DestinationType, + &r.DestinationID, &r.DestinationData, &r.Priority, &r.IsActive, &r.TrunkID, + &r.CreatedAt, &r.UpdatedAt) + if err != nil { + return nil, err + } + return &r, nil +} + +// CreateInboundRoute creates a new inbound route +func (s *PBXService) CreateInboundRoute(ctx context.Context, req *types.PBXInboundRouteCreate) (*types.PBXInboundRoute, error) { + id := uuid.New() + now := time.Now().UTC() + + priority := req.Priority + if priority == 0 { + priority = 100 + } + + _, err := s.pool.Exec(ctx, + `INSERT INTO pbx_inbound_routes (id, tenant_id, name, did_number, destination_type, + destination_id, destination_data, priority, is_active, trunk_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $10)`, + id, req.TenantID, req.Name, nilIfEmpty(req.DIDNumber), req.DestinationType, + req.DestinationID, nilIfEmpty(req.DestinationData), priority, req.TrunkID, now) + if err != nil { + return nil, fmt.Errorf("insert failed: %w", err) + } + + return s.GetInboundRoute(ctx, id) +} + +// UpdateInboundRoute updates an inbound route +func (s *PBXService) UpdateInboundRoute(ctx context.Context, id uuid.UUID, req *types.PBXInboundRouteUpdate) (*types.PBXInboundRoute, 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.DIDNumber != nil { + setClauses = append(setClauses, fmt.Sprintf("did_number = $%d", argIdx)) + args = append(args, *req.DIDNumber) + argIdx++ + } + if req.DestinationType != nil { + setClauses = append(setClauses, fmt.Sprintf("destination_type = $%d", argIdx)) + args = append(args, *req.DestinationType) + argIdx++ + } + if req.DestinationID != nil { + setClauses = append(setClauses, fmt.Sprintf("destination_id = $%d", argIdx)) + args = append(args, *req.DestinationID) + argIdx++ + } + if req.DestinationData != nil { + setClauses = append(setClauses, fmt.Sprintf("destination_data = $%d", argIdx)) + args = append(args, *req.DestinationData) + argIdx++ + } + if req.Priority != nil { + setClauses = append(setClauses, fmt.Sprintf("priority = $%d", argIdx)) + args = append(args, *req.Priority) + argIdx++ + } + if req.IsActive != nil { + setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) + args = append(args, *req.IsActive) + argIdx++ + } + if req.TrunkID != nil { + setClauses = append(setClauses, fmt.Sprintf("trunk_id = $%d", argIdx)) + args = append(args, *req.TrunkID) + argIdx++ + } + + if len(setClauses) == 0 { + return s.GetInboundRoute(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 pbx_inbound_routes 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.GetInboundRoute(ctx, id) +} + +// DeleteInboundRoute deletes an inbound route +func (s *PBXService) DeleteInboundRoute(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM pbx_inbound_routes WHERE id = $1`, id) + return err +} + +// ============================================================================ +// Outbound Routes +// ============================================================================ + +// ListOutboundRoutes lists outbound routes +func (s *PBXService) ListOutboundRoutes(ctx context.Context, params types.ListParams) ([]types.PBXOutboundRoute, int64, error) { + params = types.DefaultListParams(params) + + countQuery := `SELECT COUNT(*) FROM pbx_outbound_routes WHERE 1=1` + listQuery := `SELECT id, tenant_id, name, priority, dial_patterns, prepend, prefix, + strip_digits, override_caller_id, caller_id_name, caller_id_number, + is_emergency, is_active, created_at, updated_at + FROM pbx_outbound_routes WHERE 1=1` + + args := []interface{}{} + argIdx := 1 + + if params.Search != "" { + countQuery += fmt.Sprintf(" AND name ILIKE $%d", argIdx) + listQuery += fmt.Sprintf(" AND name ILIKE $%d", 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 priority ASC 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() + + routes := make([]types.PBXOutboundRoute, 0) + for rows.Next() { + var r types.PBXOutboundRoute + if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.Priority, &r.DialPatterns, + &r.Prepend, &r.Prefix, &r.StripDigits, &r.OverrideCallerID, + &r.CallerIDName, &r.CallerIDNumber, &r.IsEmergency, &r.IsActive, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, 0, fmt.Errorf("scan failed: %w", err) + } + + // Load trunks for this route + trunkRows, err := s.pool.Query(ctx, + `SELECT ort.trunk_id, t.name, ort.priority + FROM pbx_outbound_route_trunks ort + JOIN pbx_trunks t ON t.id = ort.trunk_id + WHERE ort.outbound_route_id = $1 ORDER BY ort.priority`, r.ID) + if err == nil { + for trunkRows.Next() { + var rt types.PBXOutboundRouteTrunk + trunkRows.Scan(&rt.TrunkID, &rt.TrunkName, &rt.Priority) + r.Trunks = append(r.Trunks, rt) + } + trunkRows.Close() + } + + routes = append(routes, r) + } + + return routes, total, nil +} + +// GetOutboundRoute gets an outbound route by ID +func (s *PBXService) GetOutboundRoute(ctx context.Context, id uuid.UUID) (*types.PBXOutboundRoute, error) { + var r types.PBXOutboundRoute + err := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, name, priority, dial_patterns, prepend, prefix, + strip_digits, override_caller_id, caller_id_name, caller_id_number, + is_emergency, is_active, created_at, updated_at + FROM pbx_outbound_routes WHERE id = $1`, id). + Scan(&r.ID, &r.TenantID, &r.Name, &r.Priority, &r.DialPatterns, + &r.Prepend, &r.Prefix, &r.StripDigits, &r.OverrideCallerID, + &r.CallerIDName, &r.CallerIDNumber, &r.IsEmergency, &r.IsActive, + &r.CreatedAt, &r.UpdatedAt) + if err != nil { + return nil, err + } + + // Load trunks + trunkRows, err := s.pool.Query(ctx, + `SELECT ort.trunk_id, t.name, ort.priority + FROM pbx_outbound_route_trunks ort + JOIN pbx_trunks t ON t.id = ort.trunk_id + WHERE ort.outbound_route_id = $1 ORDER BY ort.priority`, id) + if err == nil { + for trunkRows.Next() { + var rt types.PBXOutboundRouteTrunk + trunkRows.Scan(&rt.TrunkID, &rt.TrunkName, &rt.Priority) + r.Trunks = append(r.Trunks, rt) + } + trunkRows.Close() + } + + return &r, nil +} + +// CreateOutboundRoute creates a new outbound route +func (s *PBXService) CreateOutboundRoute(ctx context.Context, req *types.PBXOutboundRouteCreate) (*types.PBXOutboundRoute, error) { + id := uuid.New() + now := time.Now().UTC() + + priority := req.Priority + if priority == 0 { + priority = 100 + } + + _, err := s.pool.Exec(ctx, + `INSERT INTO pbx_outbound_routes (id, tenant_id, name, priority, dial_patterns, + prepend, prefix, strip_digits, override_caller_id, caller_id_name, caller_id_number, + is_emergency, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, $13, $13)`, + id, req.TenantID, req.Name, priority, req.DialPatterns, + nilIfEmpty(req.Prepend), nilIfEmpty(req.Prefix), req.StripDigits, + req.OverrideCallerID, nilIfEmpty(req.CallerIDName), nilIfEmpty(req.CallerIDNumber), + req.IsEmergency, now) + if err != nil { + return nil, fmt.Errorf("insert failed: %w", err) + } + + // Insert trunk assignments + for _, t := range req.Trunks { + s.pool.Exec(ctx, + `INSERT INTO pbx_outbound_route_trunks (id, outbound_route_id, trunk_id, priority, created_at) + VALUES ($1, $2, $3, $4, $5)`, + uuid.New(), id, t.TrunkID, t.Priority, now) + } + + return s.GetOutboundRoute(ctx, id) +} + +// UpdateOutboundRoute updates an outbound route +func (s *PBXService) UpdateOutboundRoute(ctx context.Context, id uuid.UUID, req *types.PBXOutboundRouteUpdate) (*types.PBXOutboundRoute, 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.Priority != nil { + setClauses = append(setClauses, fmt.Sprintf("priority = $%d", argIdx)) + args = append(args, *req.Priority) + argIdx++ + } + if req.DialPatterns != nil { + setClauses = append(setClauses, fmt.Sprintf("dial_patterns = $%d", argIdx)) + args = append(args, req.DialPatterns) + argIdx++ + } + if req.Prepend != nil { + setClauses = append(setClauses, fmt.Sprintf("prepend = $%d", argIdx)) + args = append(args, *req.Prepend) + argIdx++ + } + if req.Prefix != nil { + setClauses = append(setClauses, fmt.Sprintf("prefix = $%d", argIdx)) + args = append(args, *req.Prefix) + argIdx++ + } + if req.StripDigits != nil { + setClauses = append(setClauses, fmt.Sprintf("strip_digits = $%d", argIdx)) + args = append(args, *req.StripDigits) + argIdx++ + } + if req.OverrideCallerID != nil { + setClauses = append(setClauses, fmt.Sprintf("override_caller_id = $%d", argIdx)) + args = append(args, *req.OverrideCallerID) + argIdx++ + } + if req.CallerIDName != nil { + setClauses = append(setClauses, fmt.Sprintf("caller_id_name = $%d", argIdx)) + args = append(args, *req.CallerIDName) + argIdx++ + } + if req.CallerIDNumber != nil { + setClauses = append(setClauses, fmt.Sprintf("caller_id_number = $%d", argIdx)) + args = append(args, *req.CallerIDNumber) + argIdx++ + } + if req.IsEmergency != nil { + setClauses = append(setClauses, fmt.Sprintf("is_emergency = $%d", argIdx)) + args = append(args, *req.IsEmergency) + argIdx++ + } + if req.IsActive != nil { + setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) + args = append(args, *req.IsActive) + argIdx++ + } + + if len(setClauses) > 0 { + setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) + args = append(args, time.Now().UTC()) + argIdx++ + + args = append(args, id) + query := fmt.Sprintf("UPDATE pbx_outbound_routes 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) + } + } + + // Update trunk assignments if provided + if req.Trunks != nil { + s.pool.Exec(ctx, `DELETE FROM pbx_outbound_route_trunks WHERE outbound_route_id = $1`, id) + now := time.Now().UTC() + for _, t := range req.Trunks { + s.pool.Exec(ctx, + `INSERT INTO pbx_outbound_route_trunks (id, outbound_route_id, trunk_id, priority, created_at) + VALUES ($1, $2, $3, $4, $5)`, + uuid.New(), id, t.TrunkID, t.Priority, now) + } + } + + return s.GetOutboundRoute(ctx, id) +} + +// DeleteOutboundRoute deletes an outbound route +func (s *PBXService) DeleteOutboundRoute(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM pbx_outbound_routes WHERE id = $1`, id) + return err +} + +// ============================================================================ +// System Operations +// ============================================================================ + +// GetStatus returns PBX system status +func (s *PBXService) GetStatus(ctx context.Context) (*types.PBXStatus, error) { + status := &types.PBXStatus{} + + // Count active trunks + s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pbx_trunks WHERE status = 'active'`).Scan(&status.ActiveTrunks) + + // Count active extensions + s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pbx_extensions WHERE is_active = true`).Scan(&status.ActiveExtensions) + + // Asterisk server status + if s.asterisk != nil { + for _, srv := range s.asterisk.Servers() { + ss := types.PBXServerStatus{Host: srv.Host, Status: "unknown"} + results := s.asterisk.GetUptime() + for _, r := range results { + if r.Host == srv.Host { + if r.Success { + ss.Status = "online" + ss.Uptime = r.Output + } else { + ss.Status = "offline" + } + } + } + status.AsteriskServers = append(status.AsteriskServers, ss) + } + } + + // Kamailio server status + if s.kamailio != nil { + for _, srv := range s.kamailio.Servers() { + status.KamailioServers = append(status.KamailioServers, types.PBXServerStatus{ + Host: srv, + Status: "configured", + }) + } + } + + return status, nil +} + +// Reload triggers a reload on all Asterisk and Kamailio servers +func (s *PBXService) Reload(ctx context.Context) (*types.PBXReloadResult, error) { + result := &types.PBXReloadResult{} + + if s.asterisk != nil { + astResults := s.asterisk.ReloadAll() + for _, r := range astResults { + result.AsteriskResults = append(result.AsteriskResults, types.PBXReloadServer{ + Host: r.Host, + Success: r.Success, + Message: r.Output, + }) + } + } + + if s.kamailio != nil { + kamResults := s.kamailio.ReloadAll() + for _, r := range kamResults { + result.KamailioResults = append(result.KamailioResults, types.PBXReloadServer{ + Host: r.Host, + Success: r.Success, + Message: r.Output, + }) + } + } + + return result, nil +} diff --git a/internal/service/persona.go b/internal/service/persona.go new file mode 100644 index 0000000..d320068 --- /dev/null +++ b/internal/service/persona.go @@ -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 +} diff --git a/internal/service/personal_agent.go b/internal/service/personal_agent.go new file mode 100644 index 0000000..a33be3a --- /dev/null +++ b/internal/service/personal_agent.go @@ -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 +} diff --git a/internal/service/pgp.go b/internal/service/pgp.go new file mode 100644 index 0000000..6ec172d --- /dev/null +++ b/internal/service/pgp.go @@ -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 +} diff --git a/internal/service/voice_agent.go b/internal/service/voice_agent.go new file mode 100644 index 0000000..4ee223a --- /dev/null +++ b/internal/service/voice_agent.go @@ -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 +} diff --git a/migrations/001_pbx_realtime_views.sql b/migrations/001_pbx_realtime_views.sql new file mode 100644 index 0000000..e2795ec --- /dev/null +++ b/migrations/001_pbx_realtime_views.sql @@ -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 +-- ============================================================================ diff --git a/pkg/types/errors.go b/pkg/types/errors.go new file mode 100644 index 0000000..d27888a --- /dev/null +++ b/pkg/types/errors.go @@ -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} +} diff --git a/pkg/types/models.go b/pkg/types/models.go new file mode 100644 index 0000000..06d896b --- /dev/null +++ b/pkg/types/models.go @@ -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 +} diff --git a/pkg/types/pbx.go b/pkg/types/pbx.go new file mode 100644 index 0000000..b5d5d88 --- /dev/null +++ b/pkg/types/pbx.go @@ -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"` +} diff --git a/pkg/types/persona.go b/pkg/types/persona.go new file mode 100644 index 0000000..14dc68f --- /dev/null +++ b/pkg/types/persona.go @@ -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"` +} diff --git a/pkg/types/personal_agent.go b/pkg/types/personal_agent.go new file mode 100644 index 0000000..6822924 --- /dev/null +++ b/pkg/types/personal_agent.go @@ -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"` +} diff --git a/pkg/types/response.go b/pkg/types/response.go new file mode 100644 index 0000000..3e6812f --- /dev/null +++ b/pkg/types/response.go @@ -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, + } +} diff --git a/pkg/types/voice_agent.go b/pkg/types/voice_agent.go new file mode 100644 index 0000000..2242ae8 --- /dev/null +++ b/pkg/types/voice_agent.go @@ -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"` +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..bf69c5c --- /dev/null +++ b/scripts/install.sh @@ -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"