Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.
The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.
Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.
Future changes go through PRs against this repo — no more bin-only
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
191
internal/client/asterisk.go
Normal file
191
internal/client/asterisk.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// AsteriskServer defines an Asterisk AMI endpoint
|
||||
type AsteriskServer struct {
|
||||
Host string `yaml:"host"`
|
||||
AMIPort int `yaml:"amiPort"`
|
||||
}
|
||||
|
||||
// AsteriskClient manages AMI connections to Asterisk servers
|
||||
type AsteriskClient struct {
|
||||
servers []AsteriskServer
|
||||
user string
|
||||
secret string
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewAsteriskClient creates a new Asterisk AMI client
|
||||
func NewAsteriskClient(servers []AsteriskServer, user, secret string, logger zerolog.Logger) *AsteriskClient {
|
||||
return &AsteriskClient{
|
||||
servers: servers,
|
||||
user: user,
|
||||
secret: secret,
|
||||
logger: logger.With().Str("client", "asterisk").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadPJSIP sends a PJSIP reload command to all Asterisk servers
|
||||
func (c *AsteriskClient) ReloadPJSIP() []ServerResult {
|
||||
return c.runOnAll("pjsip reload")
|
||||
}
|
||||
|
||||
// ReloadDialplan sends a dialplan reload to all Asterisk servers
|
||||
func (c *AsteriskClient) ReloadDialplan() []ServerResult {
|
||||
return c.runOnAll("dialplan reload")
|
||||
}
|
||||
|
||||
// ReloadAll reloads both PJSIP and dialplan on all servers
|
||||
func (c *AsteriskClient) ReloadAll() []ServerResult {
|
||||
results := c.runOnAll("core reload")
|
||||
return results
|
||||
}
|
||||
|
||||
// GetChannelCount returns active channel count from each server
|
||||
func (c *AsteriskClient) GetChannelCount() []ServerResult {
|
||||
return c.runOnAll("core show channels count")
|
||||
}
|
||||
|
||||
// GetUptime returns uptime from each server
|
||||
func (c *AsteriskClient) GetUptime() []ServerResult {
|
||||
return c.runOnAll("core show uptime")
|
||||
}
|
||||
|
||||
// ServerResult holds the result from an AMI command on a single server
|
||||
type ServerResult struct {
|
||||
Host string `json:"host"`
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// runOnAll executes an AMI command on all Asterisk servers in parallel
|
||||
func (c *AsteriskClient) runOnAll(command string) []ServerResult {
|
||||
var wg sync.WaitGroup
|
||||
results := make([]ServerResult, len(c.servers))
|
||||
|
||||
for i, srv := range c.servers {
|
||||
wg.Add(1)
|
||||
go func(idx int, server AsteriskServer) {
|
||||
defer wg.Done()
|
||||
results[idx] = c.execAMI(server, command)
|
||||
}(i, srv)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// execAMI connects to a single Asterisk AMI server and executes a command
|
||||
func (c *AsteriskClient) execAMI(server AsteriskServer, command string) ServerResult {
|
||||
addr := fmt.Sprintf("%s:%d", server.Host, server.AMIPort)
|
||||
result := ServerResult{Host: server.Host}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("connection failed: %v", err)
|
||||
c.logger.Warn().Str("host", server.Host).Err(err).Msg("AMI connection failed")
|
||||
return result
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// Read AMI banner
|
||||
banner, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to read banner: %v", err)
|
||||
return result
|
||||
}
|
||||
if !strings.HasPrefix(banner, "Asterisk Call Manager") {
|
||||
result.Error = fmt.Sprintf("unexpected banner: %s", strings.TrimSpace(banner))
|
||||
return result
|
||||
}
|
||||
|
||||
// Login
|
||||
loginMsg := fmt.Sprintf("Action: Login\r\nUsername: %s\r\nSecret: %s\r\n\r\n",
|
||||
c.user, c.secret)
|
||||
if _, err := conn.Write([]byte(loginMsg)); err != nil {
|
||||
result.Error = fmt.Sprintf("login write failed: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Read login response
|
||||
loginResp, err := readAMIResponse(reader)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("login response failed: %v", err)
|
||||
return result
|
||||
}
|
||||
if !strings.Contains(loginResp, "Success") {
|
||||
result.Error = fmt.Sprintf("login failed: %s", loginResp)
|
||||
return result
|
||||
}
|
||||
|
||||
// Execute command
|
||||
cmdMsg := fmt.Sprintf("Action: Command\r\nCommand: %s\r\n\r\n", command)
|
||||
if _, err := conn.Write([]byte(cmdMsg)); err != nil {
|
||||
result.Error = fmt.Sprintf("command write failed: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Read command response
|
||||
cmdResp, err := readAMIResponse(reader)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("command response failed: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Logoff
|
||||
conn.Write([]byte("Action: Logoff\r\n\r\n"))
|
||||
|
||||
result.Success = true
|
||||
result.Output = cmdResp
|
||||
c.logger.Debug().Str("host", server.Host).Str("command", command).Msg("AMI command executed")
|
||||
return result
|
||||
}
|
||||
|
||||
// readAMIResponse reads a complete AMI response (terminated by blank line)
|
||||
func readAMIResponse(reader *bufio.Reader) (string, error) {
|
||||
var lines []string
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return strings.Join(lines, "\n"), err
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
break
|
||||
}
|
||||
lines = append(lines, trimmed)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// Health checks connectivity to all Asterisk servers
|
||||
func (c *AsteriskClient) Health() error {
|
||||
for _, srv := range c.servers {
|
||||
addr := fmt.Sprintf("%s:%d", srv.Host, srv.AMIPort)
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("asterisk %s unreachable: %w", srv.Host, err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Servers returns the configured server list
|
||||
func (c *AsteriskClient) Servers() []AsteriskServer {
|
||||
return c.servers
|
||||
}
|
||||
67
internal/client/carddav.go
Normal file
67
internal/client/carddav.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
)
|
||||
|
||||
// CardDAVClient wraps a pgx connection pool for the sabredav database
|
||||
type CardDAVClient struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewCardDAVClient creates a new CardDAV database client
|
||||
func NewCardDAVClient(cfg config.CardDAVConfig, dsn string, logger zerolog.Logger) (*CardDAVClient, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse carddav database config: %w", err)
|
||||
}
|
||||
|
||||
poolConfig.MaxConns = 10
|
||||
poolConfig.MinConns = 2
|
||||
poolConfig.MaxConnLifetime = 1 * time.Hour
|
||||
poolConfig.MaxConnIdleTime = 30 * time.Minute
|
||||
poolConfig.HealthCheckPeriod = 1 * time.Minute
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create carddav connection pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("failed to ping carddav database: %w", err)
|
||||
}
|
||||
|
||||
return &CardDAVClient{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("client", "carddav").Logger(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Pool returns the underlying connection pool
|
||||
func (c *CardDAVClient) Pool() *pgxpool.Pool {
|
||||
return c.pool
|
||||
}
|
||||
|
||||
// Health checks the database connection
|
||||
func (c *CardDAVClient) Health(ctx context.Context) error {
|
||||
return c.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// Close closes the connection pool
|
||||
func (c *CardDAVClient) Close() {
|
||||
if c.pool != nil {
|
||||
c.pool.Close()
|
||||
}
|
||||
}
|
||||
188
internal/client/ejbca.go
Normal file
188
internal/client/ejbca.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
)
|
||||
|
||||
// EJBCAClient is an mTLS HTTP client for the EJBCA REST API
|
||||
type EJBCAClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewEJBCAClient creates a new EJBCA client with mTLS
|
||||
func NewEJBCAClient(cfg config.EJBCAConfig, logger zerolog.Logger) (*EJBCAClient, error) {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load EJBCA client cert: %w", err)
|
||||
}
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
if cfg.CAFile != "" {
|
||||
caCert, err := os.ReadFile(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read EJBCA CA file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caCert)
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
return &EJBCAClient{
|
||||
baseURL: cfg.BaseURL,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
},
|
||||
logger: logger.With().Str("component", "ejbca").Logger(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EJBCACert represents a certificate from EJBCA
|
||||
type EJBCACert struct {
|
||||
SerialNumber string `json:"serial_number"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
Status string `json:"status"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
CertificateData string `json:"certificate"`
|
||||
CAName string `json:"ca_name,omitempty"`
|
||||
}
|
||||
|
||||
// CertSearchRequest is the request body for searching certificates
|
||||
type CertSearchRequest struct {
|
||||
MaxResults int `json:"max_number_of_results"`
|
||||
Criteria []CertSearchCriterion `json:"criteria"`
|
||||
}
|
||||
|
||||
// CertSearchCriterion is a single search criterion
|
||||
type CertSearchCriterion struct {
|
||||
Property string `json:"property"`
|
||||
Value string `json:"value"`
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
// CertEnrollRequest is the request body for enrolling a certificate
|
||||
type CertEnrollRequest struct {
|
||||
CertificateRequest string `json:"certificate_request,omitempty"`
|
||||
CertificateProfileName string `json:"certificate_profile_name"`
|
||||
EndEntityProfileName string `json:"end_entity_profile_name"`
|
||||
CAName string `json:"certificate_authority_name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IncludeChain bool `json:"include_chain"`
|
||||
SubjectAltName string `json:"subject_alternative_name,omitempty"`
|
||||
}
|
||||
|
||||
// CertRevokeRequest is the request body for revoking a certificate
|
||||
type CertRevokeRequest struct {
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (c *EJBCAClient) do(method, path string, body interface{}, result interface{}) error {
|
||||
url := c.baseURL + path
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("EJBCA error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchCertificates searches for certificates matching criteria
|
||||
func (c *EJBCAClient) SearchCertificates(req *CertSearchRequest) ([]EJBCACert, error) {
|
||||
var result struct {
|
||||
Certificates []EJBCACert `json:"certificates"`
|
||||
}
|
||||
err := c.do("POST", "/ejbca/ejbca-rest-api/v1/certificate/search", req, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Certificates, nil
|
||||
}
|
||||
|
||||
// GetCertificate gets a certificate by serial number and issuer DN
|
||||
func (c *EJBCAClient) GetCertificate(issuerDN, serialNumber string) (*EJBCACert, error) {
|
||||
path := fmt.Sprintf("/ejbca/ejbca-rest-api/v1/certificate/%s/%s", issuerDN, serialNumber)
|
||||
var cert EJBCACert
|
||||
err := c.do("GET", path, nil, &cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// EnrollCertificate requests a new certificate
|
||||
func (c *EJBCAClient) EnrollCertificate(req *CertEnrollRequest) (*EJBCACert, error) {
|
||||
var cert EJBCACert
|
||||
err := c.do("POST", "/ejbca/ejbca-rest-api/v1/certificate/enrollkeystore", req, &cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate
|
||||
func (c *EJBCAClient) RevokeCertificate(issuerDN, serialNumber, reason string) error {
|
||||
path := fmt.Sprintf("/ejbca/ejbca-rest-api/v1/certificate/%s/%s/revoke?reason=%s",
|
||||
issuerDN, serialNumber, reason)
|
||||
return c.do("PUT", path, nil, nil)
|
||||
}
|
||||
|
||||
// Health checks EJBCA connectivity
|
||||
func (c *EJBCAClient) Health() error {
|
||||
return c.do("GET", "/ejbca/ejbca-rest-api/v1/certificate/status", nil, nil)
|
||||
}
|
||||
162
internal/client/hockeypuck.go
Normal file
162
internal/client/hockeypuck.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
)
|
||||
|
||||
// HockeypuckClient implements the HKP (HTTP Keyserver Protocol) client
|
||||
type HockeypuckClient struct {
|
||||
servers []string
|
||||
client *http.Client
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewHockeypuckClient creates a new Hockeypuck HKP client
|
||||
func NewHockeypuckClient(cfg config.HockeypuckConfig, logger zerolog.Logger) *HockeypuckClient {
|
||||
return &HockeypuckClient{
|
||||
servers: cfg.Servers,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
logger: logger.With().Str("component", "hockeypuck").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchKeys searches for PGP keys by query string (email, name, or key ID)
|
||||
func (c *HockeypuckClient) SearchKeys(query string) (string, error) {
|
||||
params := url.Values{
|
||||
"search": {query},
|
||||
"op": {"index"},
|
||||
"options": {"mr"},
|
||||
}
|
||||
|
||||
for _, server := range c.servers {
|
||||
u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode())
|
||||
resp, err := c.client.Get(u)
|
||||
if err != nil {
|
||||
c.logger.Warn().Err(err).Str("server", server).Msg("HKP search failed")
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
return "", fmt.Errorf("all HKP servers failed")
|
||||
}
|
||||
|
||||
// GetKey retrieves a PGP key by key ID
|
||||
func (c *HockeypuckClient) GetKey(keyID string) (string, error) {
|
||||
// Ensure keyID has 0x prefix
|
||||
if !strings.HasPrefix(keyID, "0x") {
|
||||
keyID = "0x" + keyID
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"search": {keyID},
|
||||
"op": {"get"},
|
||||
"options": {"mr"},
|
||||
}
|
||||
|
||||
for _, server := range c.servers {
|
||||
u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode())
|
||||
resp, err := c.client.Get(u)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
return "", fmt.Errorf("all HKP servers failed")
|
||||
}
|
||||
|
||||
// UploadKey uploads a PGP public key
|
||||
func (c *HockeypuckClient) UploadKey(armoredKey string) error {
|
||||
form := url.Values{
|
||||
"keytext": {armoredKey},
|
||||
}
|
||||
|
||||
for _, server := range c.servers {
|
||||
u := fmt.Sprintf("%s/pks/add", server)
|
||||
resp, err := c.client.PostForm(u, form)
|
||||
if err != nil {
|
||||
c.logger.Warn().Err(err).Str("server", server).Msg("HKP upload failed")
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to upload key to any HKP server")
|
||||
}
|
||||
|
||||
// DeleteKey deletes a PGP key (Hockeypuck-specific API, not standard HKP)
|
||||
func (c *HockeypuckClient) DeleteKey(keyID string) error {
|
||||
if !strings.HasPrefix(keyID, "0x") {
|
||||
keyID = "0x" + keyID
|
||||
}
|
||||
|
||||
for _, server := range c.servers {
|
||||
u := fmt.Sprintf("%s/pks/delete?search=%s", server, url.QueryEscape(keyID))
|
||||
req, err := http.NewRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to delete key from any HKP server")
|
||||
}
|
||||
|
||||
// Health checks HKP server connectivity
|
||||
func (c *HockeypuckClient) Health() error {
|
||||
for _, server := range c.servers {
|
||||
resp, err := c.client.Get(server + "/pks/lookup?op=stats")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no HKP servers reachable")
|
||||
}
|
||||
125
internal/client/kamailio.go
Normal file
125
internal/client/kamailio.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// KamailioClient manages SSH connections to Kamailio servers for kamcmd
|
||||
type KamailioClient struct {
|
||||
servers []string
|
||||
sshUser string
|
||||
sshKey []byte
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewKamailioClient creates a new Kamailio management client
|
||||
func NewKamailioClient(servers []string, sshUser string, sshKey []byte, logger zerolog.Logger) *KamailioClient {
|
||||
return &KamailioClient{
|
||||
servers: servers,
|
||||
sshUser: sshUser,
|
||||
sshKey: sshKey,
|
||||
logger: logger.With().Str("client", "kamailio").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadDispatcher reloads the dispatcher module on all Kamailio servers
|
||||
func (c *KamailioClient) ReloadDispatcher() []ServerResult {
|
||||
return c.runOnAll("kamcmd dispatcher.reload")
|
||||
}
|
||||
|
||||
// ReloadPermissions reloads address permissions on all Kamailio servers
|
||||
func (c *KamailioClient) ReloadPermissions() []ServerResult {
|
||||
return c.runOnAll("kamcmd permissions.addressReload")
|
||||
}
|
||||
|
||||
// ReloadAll reloads dispatcher and permissions on all servers
|
||||
func (c *KamailioClient) ReloadAll() []ServerResult {
|
||||
results := make([]ServerResult, 0, len(c.servers)*2)
|
||||
r1 := c.ReloadDispatcher()
|
||||
r2 := c.ReloadPermissions()
|
||||
results = append(results, r1...)
|
||||
results = append(results, r2...)
|
||||
return results
|
||||
}
|
||||
|
||||
// runOnAll executes a command on all Kamailio servers in parallel
|
||||
func (c *KamailioClient) runOnAll(command string) []ServerResult {
|
||||
var wg sync.WaitGroup
|
||||
results := make([]ServerResult, len(c.servers))
|
||||
|
||||
for i, srv := range c.servers {
|
||||
wg.Add(1)
|
||||
go func(idx int, host string) {
|
||||
defer wg.Done()
|
||||
results[idx] = c.execSSH(host, command)
|
||||
}(i, srv)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// execSSH connects via SSH and executes a command
|
||||
func (c *KamailioClient) execSSH(host, command string) ServerResult {
|
||||
result := ServerResult{Host: host}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(c.sshKey)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to parse SSH key: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: c.sshUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", host+":22", config)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("SSH connection failed: %v", err)
|
||||
c.logger.Warn().Str("host", host).Err(err).Msg("Kamailio SSH connection failed")
|
||||
return result
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("SSH session failed: %v", err)
|
||||
return result
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(command)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("command failed: %v, output: %s", err, string(output))
|
||||
return result
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.Output = string(output)
|
||||
c.logger.Debug().Str("host", host).Str("command", command).Msg("Kamailio command executed")
|
||||
return result
|
||||
}
|
||||
|
||||
// Health checks SSH connectivity to all Kamailio servers
|
||||
func (c *KamailioClient) Health() error {
|
||||
for _, srv := range c.servers {
|
||||
r := c.execSSH(srv, "kamcmd core.uptime")
|
||||
if !r.Success {
|
||||
return fmt.Errorf("kamailio %s: %s", srv, r.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Servers returns the configured server list
|
||||
func (c *KamailioClient) Servers() []string {
|
||||
return c.servers
|
||||
}
|
||||
226
internal/client/ldap.go
Normal file
226
internal/client/ldap.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
)
|
||||
|
||||
// LDAPClient manages a pool of LDAP connections
|
||||
type LDAPClient struct {
|
||||
cfg config.LDAPConfig
|
||||
pool chan *ldap.Conn
|
||||
mu sync.Mutex
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewLDAPClient creates a new LDAP client with a connection pool
|
||||
func NewLDAPClient(cfg config.LDAPConfig, logger zerolog.Logger) (*LDAPClient, error) {
|
||||
if len(cfg.Servers) == 0 {
|
||||
return nil, fmt.Errorf("no LDAP servers configured")
|
||||
}
|
||||
|
||||
c := &LDAPClient{
|
||||
cfg: cfg,
|
||||
pool: make(chan *ldap.Conn, cfg.PoolSize),
|
||||
logger: logger.With().Str("component", "ldap").Logger(),
|
||||
}
|
||||
|
||||
// Pre-fill pool with connections
|
||||
for i := 0; i < cfg.PoolSize; i++ {
|
||||
conn, err := c.connect()
|
||||
if err != nil {
|
||||
c.logger.Warn().Err(err).Int("index", i).Msg("failed to create initial LDAP connection")
|
||||
continue
|
||||
}
|
||||
c.pool <- conn
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *LDAPClient) connect() (*ldap.Conn, error) {
|
||||
var lastErr error
|
||||
for _, server := range c.cfg.Servers {
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
|
||||
if c.cfg.UseTLS {
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if c.cfg.CAFile != "" {
|
||||
caCert, err := os.ReadFile(c.cfg.CAFile)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to read CA file: %w", err)
|
||||
continue
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caCert)
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
conn, err = ldap.DialURL(server, ldap.DialWithTLSConfig(tlsCfg))
|
||||
} else {
|
||||
conn, err = ldap.DialURL(server)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to connect to %s: %w", server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
conn.SetTimeout(10 * time.Second)
|
||||
|
||||
if err := conn.Bind(c.cfg.BindDN, c.cfg.BindPass); err != nil {
|
||||
conn.Close()
|
||||
lastErr = fmt.Errorf("failed to bind to %s: %w", server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
return nil, fmt.Errorf("all LDAP servers failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// Acquire gets a connection from the pool, creating one if needed
|
||||
func (c *LDAPClient) Acquire() (*ldap.Conn, error) {
|
||||
select {
|
||||
case conn := <-c.pool:
|
||||
// Test the connection with a no-op search
|
||||
_, err := conn.Search(&ldap.SearchRequest{
|
||||
BaseDN: "",
|
||||
Scope: ldap.ScopeBaseObject,
|
||||
Filter: "(objectClass=*)",
|
||||
SizeLimit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return c.connect()
|
||||
}
|
||||
return conn, nil
|
||||
default:
|
||||
return c.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Release returns a connection to the pool
|
||||
func (c *LDAPClient) Release(conn *ldap.Conn) {
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.pool <- conn:
|
||||
default:
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes all pooled connections
|
||||
func (c *LDAPClient) Close() {
|
||||
close(c.pool)
|
||||
for conn := range c.pool {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Health checks LDAP connectivity
|
||||
func (c *LDAPClient) Health() error {
|
||||
conn, err := c.Acquire()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Release(conn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search executes an LDAP search
|
||||
func (c *LDAPClient) Search(baseDN, filter string, attrs []string, sizeLimit int) ([]*ldap.Entry, error) {
|
||||
conn, err := c.Acquire()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to acquire LDAP connection: %w", err)
|
||||
}
|
||||
defer c.Release(conn)
|
||||
|
||||
sr, err := conn.Search(&ldap.SearchRequest{
|
||||
BaseDN: baseDN,
|
||||
Scope: ldap.ScopeWholeSubtree,
|
||||
Filter: filter,
|
||||
Attributes: attrs,
|
||||
SizeLimit: sizeLimit,
|
||||
})
|
||||
if err != nil {
|
||||
// FreeIPA returns SizeLimitExceeded with partial results
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) && sr != nil {
|
||||
return sr.Entries, nil
|
||||
}
|
||||
return nil, fmt.Errorf("LDAP search failed: %w", err)
|
||||
}
|
||||
|
||||
return sr.Entries, nil
|
||||
}
|
||||
|
||||
// SearchOne executes an LDAP search expecting exactly one result
|
||||
func (c *LDAPClient) SearchOne(baseDN, filter string, attrs []string) (*ldap.Entry, error) {
|
||||
entries, err := c.Search(baseDN, filter, attrs, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return entries[0], nil
|
||||
}
|
||||
|
||||
// Add adds an LDAP entry
|
||||
func (c *LDAPClient) Add(req *ldap.AddRequest) error {
|
||||
conn, err := c.Acquire()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
|
||||
}
|
||||
defer c.Release(conn)
|
||||
|
||||
return conn.Add(req)
|
||||
}
|
||||
|
||||
// Modify modifies an LDAP entry
|
||||
func (c *LDAPClient) Modify(req *ldap.ModifyRequest) error {
|
||||
conn, err := c.Acquire()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
|
||||
}
|
||||
defer c.Release(conn)
|
||||
|
||||
return conn.Modify(req)
|
||||
}
|
||||
|
||||
// Delete deletes an LDAP entry
|
||||
func (c *LDAPClient) Delete(dn string) error {
|
||||
conn, err := c.Acquire()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
|
||||
}
|
||||
defer c.Release(conn)
|
||||
|
||||
return conn.Del(&ldap.DelRequest{DN: dn})
|
||||
}
|
||||
|
||||
// PasswordModify changes a user's password
|
||||
func (c *LDAPClient) PasswordModify(userDN, newPassword string) error {
|
||||
conn, err := c.Acquire()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
|
||||
}
|
||||
defer c.Release(conn)
|
||||
|
||||
_, err = conn.PasswordModify(&ldap.PasswordModifyRequest{
|
||||
UserIdentity: userDN,
|
||||
NewPassword: newPassword,
|
||||
})
|
||||
return err
|
||||
}
|
||||
180
internal/client/powerdns.go
Normal file
180
internal/client/powerdns.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
)
|
||||
|
||||
// PowerDNSClient is an HTTP client for the PowerDNS API
|
||||
type PowerDNSClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
serverID string
|
||||
client *http.Client
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewPowerDNSClient creates a new PowerDNS client
|
||||
func NewPowerDNSClient(cfg config.PowerDNSConfig, logger zerolog.Logger) *PowerDNSClient {
|
||||
return &PowerDNSClient{
|
||||
baseURL: cfg.BaseURL,
|
||||
apiKey: cfg.APIKey,
|
||||
serverID: cfg.ServerID,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
logger: logger.With().Str("component", "powerdns").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// RRSet represents a PowerDNS resource record set
|
||||
type RRSet struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int `json:"ttl"`
|
||||
ChangeType string `json:"changetype,omitempty"`
|
||||
Records []Record `json:"records"`
|
||||
Comments []Comment `json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
// Record represents a single DNS record
|
||||
type Record struct {
|
||||
Content string `json:"content"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// Comment represents a comment on an RRSet
|
||||
type Comment struct {
|
||||
Content string `json:"content"`
|
||||
Account string `json:"account"`
|
||||
ModifiedAt int64 `json:"modified_at"`
|
||||
}
|
||||
|
||||
// Zone represents a PowerDNS zone
|
||||
type Zone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
DNSSec bool `json:"dnssec"`
|
||||
Serial int64 `json:"serial"`
|
||||
NotifiedSerial int64 `json:"notified_serial"`
|
||||
SOAEdit string `json:"soa_edit,omitempty"`
|
||||
SOAEditAPI string `json:"soa_edit_api,omitempty"`
|
||||
RRSets []RRSet `json:"rrsets,omitempty"`
|
||||
Nameservers []string `json:"nameservers,omitempty"`
|
||||
Masters []string `json:"masters,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneCreate is the request body for creating a zone
|
||||
type ZoneCreate struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Masters []string `json:"masters,omitempty"`
|
||||
}
|
||||
|
||||
func (c *PowerDNSClient) do(method, path string, body interface{}, result interface{}) error {
|
||||
url := fmt.Sprintf("%s/api/v1/servers/%s%s", c.baseURL, c.serverID, path)
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("X-API-Key", c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("PowerDNS error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListZones lists all zones
|
||||
func (c *PowerDNSClient) ListZones() ([]Zone, error) {
|
||||
var zones []Zone
|
||||
err := c.do("GET", "/zones", nil, &zones)
|
||||
return zones, err
|
||||
}
|
||||
|
||||
// GetZone gets a zone by ID with RRSets
|
||||
func (c *PowerDNSClient) GetZone(zoneID string) (*Zone, error) {
|
||||
var zone Zone
|
||||
err := c.do("GET", "/zones/"+zoneID, nil, &zone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &zone, nil
|
||||
}
|
||||
|
||||
// CreateZone creates a new zone
|
||||
func (c *PowerDNSClient) CreateZone(zone *ZoneCreate) (*Zone, error) {
|
||||
var result Zone
|
||||
err := c.do("POST", "/zones", zone, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateZone updates zone metadata (PATCH to /zones/:id is for metadata only)
|
||||
func (c *PowerDNSClient) UpdateZone(zoneID string, data map[string]interface{}) error {
|
||||
return c.do("PUT", "/zones/"+zoneID, data, nil)
|
||||
}
|
||||
|
||||
// DeleteZone deletes a zone
|
||||
func (c *PowerDNSClient) DeleteZone(zoneID string) error {
|
||||
return c.do("DELETE", "/zones/"+zoneID, nil, nil)
|
||||
}
|
||||
|
||||
// NotifyZone sends NOTIFY to slaves
|
||||
func (c *PowerDNSClient) NotifyZone(zoneID string) error {
|
||||
return c.do("PUT", "/zones/"+zoneID+"/notify", nil, nil)
|
||||
}
|
||||
|
||||
// PatchRRSets patches record sets in a zone (create, update, or delete)
|
||||
func (c *PowerDNSClient) PatchRRSets(zoneID string, rrsets []RRSet) error {
|
||||
body := map[string]interface{}{
|
||||
"rrsets": rrsets,
|
||||
}
|
||||
return c.do("PATCH", "/zones/"+zoneID, body, nil)
|
||||
}
|
||||
|
||||
// Health checks PowerDNS connectivity
|
||||
func (c *PowerDNSClient) Health() error {
|
||||
_, err := c.ListZones()
|
||||
return err
|
||||
}
|
||||
377
internal/config/config.go
Normal file
377
internal/config/config.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Databases map[string]DatabaseConfig `yaml:"databases"`
|
||||
TLS TLSConfig `yaml:"tls"`
|
||||
LDAP LDAPConfig `yaml:"ldap"`
|
||||
PowerDNS PowerDNSConfig `yaml:"powerdns"`
|
||||
EJBCA EJBCAConfig `yaml:"ejbca"`
|
||||
Hockeypuck HockeypuckConfig `yaml:"hockeypuck"`
|
||||
Infisical InfisicalConfig `yaml:"infisical"`
|
||||
CardDAV CardDAVConfig `yaml:"carddav"`
|
||||
Asterisk AsteriskConfig `yaml:"asterisk"`
|
||||
Kamailio KamailioConfig `yaml:"kamailio"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
ReadTimeout time.Duration `yaml:"readTimeout"`
|
||||
WriteTimeout time.Duration `yaml:"writeTimeout"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
Schema string `yaml:"schema"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
SSLMode string `yaml:"sslMode"`
|
||||
MaxConns int `yaml:"maxConns"`
|
||||
MinConns int `yaml:"minConns"`
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
CertFile string `yaml:"certFile"`
|
||||
KeyFile string `yaml:"keyFile"`
|
||||
CAFile string `yaml:"caFile"`
|
||||
}
|
||||
|
||||
type LDAPConfig struct {
|
||||
Servers []string `yaml:"servers"`
|
||||
BaseDN string `yaml:"baseDn"`
|
||||
BindDN string `yaml:"bindDn"`
|
||||
BindPass string `yaml:"bindPassword"`
|
||||
PoolSize int `yaml:"poolSize"`
|
||||
UseTLS bool `yaml:"useTls"`
|
||||
CAFile string `yaml:"caFile"`
|
||||
}
|
||||
|
||||
type PowerDNSConfig struct {
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
APIKey string `yaml:"apiKey"`
|
||||
ServerID string `yaml:"serverId"`
|
||||
}
|
||||
|
||||
type EJBCAConfig struct {
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
CertFile string `yaml:"certFile"`
|
||||
KeyFile string `yaml:"keyFile"`
|
||||
CAFile string `yaml:"caFile"`
|
||||
}
|
||||
|
||||
type HockeypuckConfig struct {
|
||||
Servers []string `yaml:"servers"`
|
||||
}
|
||||
|
||||
type CardDAVConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
SSLMode string `yaml:"sslMode"`
|
||||
}
|
||||
|
||||
type AsteriskServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
AMIPort int `yaml:"amiPort"`
|
||||
}
|
||||
|
||||
type AsteriskConfig struct {
|
||||
Servers []AsteriskServerConfig `yaml:"servers"`
|
||||
AMIUser string `yaml:"amiUser"`
|
||||
AMISecret string `yaml:"amiSecret"`
|
||||
}
|
||||
|
||||
type KamailioConfig struct {
|
||||
Servers []string `yaml:"servers"`
|
||||
SSHUser string `yaml:"sshUser"`
|
||||
SSHKey string `yaml:"sshKeyFile"`
|
||||
}
|
||||
|
||||
type InfisicalConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
ProjectID string `yaml:"projectId"`
|
||||
Environment string `yaml:"environment"`
|
||||
TokenFile string `yaml:"tokenFile"`
|
||||
SecretPath string `yaml:"secretPath"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
APIKeys []string `yaml:"apiKeys"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
cfg.setDefaults()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.Host == "" {
|
||||
c.Server.Host = "0.0.0.0"
|
||||
}
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8443
|
||||
}
|
||||
if c.Server.ReadTimeout == 0 {
|
||||
c.Server.ReadTimeout = 30 * time.Second
|
||||
}
|
||||
if c.Server.WriteTimeout == 0 {
|
||||
c.Server.WriteTimeout = 30 * time.Second
|
||||
}
|
||||
// Legacy single database config
|
||||
if c.Database.Port == 0 {
|
||||
c.Database.Port = 5432
|
||||
}
|
||||
if c.Database.SSLMode == "" {
|
||||
c.Database.SSLMode = "require"
|
||||
}
|
||||
if c.Database.MaxConns == 0 {
|
||||
c.Database.MaxConns = 25
|
||||
}
|
||||
if c.Database.MinConns == 0 {
|
||||
c.Database.MinConns = 5
|
||||
}
|
||||
// Multi-database defaults
|
||||
if c.Databases == nil {
|
||||
c.Databases = make(map[string]DatabaseConfig)
|
||||
}
|
||||
for name, db := range c.Databases {
|
||||
if db.Port == 0 {
|
||||
db.Port = 5432
|
||||
}
|
||||
if db.SSLMode == "" {
|
||||
db.SSLMode = c.Database.SSLMode
|
||||
}
|
||||
if db.Host == "" {
|
||||
db.Host = c.Database.Host
|
||||
}
|
||||
if db.MaxConns == 0 {
|
||||
db.MaxConns = 10
|
||||
}
|
||||
if db.MinConns == 0 {
|
||||
db.MinConns = 2
|
||||
}
|
||||
c.Databases[name] = db
|
||||
}
|
||||
if c.LDAP.PoolSize == 0 {
|
||||
c.LDAP.PoolSize = 10
|
||||
}
|
||||
if c.PowerDNS.ServerID == "" {
|
||||
c.PowerDNS.ServerID = "localhost"
|
||||
}
|
||||
if c.CardDAV.Port == 0 {
|
||||
c.CardDAV.Port = 5432
|
||||
}
|
||||
if c.CardDAV.SSLMode == "" {
|
||||
c.CardDAV.SSLMode = "disable"
|
||||
}
|
||||
if c.Asterisk.AMIUser == "" {
|
||||
c.Asterisk.AMIUser = "gsc-ops-api"
|
||||
}
|
||||
for i := range c.Asterisk.Servers {
|
||||
if c.Asterisk.Servers[i].AMIPort == 0 {
|
||||
c.Asterisk.Servers[i].AMIPort = 5038
|
||||
}
|
||||
}
|
||||
if c.Kamailio.SSHUser == "" {
|
||||
c.Kamailio.SSHUser = "root"
|
||||
}
|
||||
if c.Logging.Level == "" {
|
||||
c.Logging.Level = "info"
|
||||
}
|
||||
if c.Logging.Format == "" {
|
||||
c.Logging.Format = "json"
|
||||
}
|
||||
if c.Infisical.SecretPath == "" {
|
||||
c.Infisical.SecretPath = "/gsc-ops-api"
|
||||
}
|
||||
}
|
||||
|
||||
// LoadSecretsFromInfisical fetches secrets from the Infisical API
|
||||
func (c *Config) LoadSecretsFromInfisical() error {
|
||||
if c.Infisical.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tokenFile := c.Infisical.TokenFile
|
||||
if tokenFile == "" {
|
||||
tokenFile = "/etc/gsc-ops-api/.infisical"
|
||||
}
|
||||
|
||||
tokenData, err := os.ReadFile(tokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read Infisical token: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(string(tokenData))
|
||||
|
||||
// Legacy single database credentials
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_DB_USER"); err == nil && v != "" {
|
||||
c.Database.User = v
|
||||
}
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_DB_PASSWORD"); err == nil && v != "" {
|
||||
c.Database.Password = v
|
||||
}
|
||||
|
||||
// Per-database credentials: inherit from primary DB user/password
|
||||
for name, db := range c.Databases {
|
||||
if db.User == "" {
|
||||
db.User = c.Database.User
|
||||
}
|
||||
if db.Password == "" {
|
||||
db.Password = c.Database.Password
|
||||
}
|
||||
c.Databases[name] = db
|
||||
}
|
||||
|
||||
// LDAP credentials
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_LDAP_BIND_DN"); err == nil && v != "" {
|
||||
c.LDAP.BindDN = v
|
||||
}
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_LDAP_BIND_PASSWORD"); err == nil && v != "" {
|
||||
c.LDAP.BindPass = v
|
||||
}
|
||||
|
||||
// PowerDNS API key
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_PDNS_API_KEY"); err == nil && v != "" {
|
||||
c.PowerDNS.APIKey = v
|
||||
}
|
||||
|
||||
// CardDAV DB password
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_CARDDAV_DB_PASSWORD"); err == nil && v != "" {
|
||||
c.CardDAV.Password = v
|
||||
}
|
||||
|
||||
// API keys for authorized clients
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_SKILL_SERVER"); err == nil && v != "" {
|
||||
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
|
||||
}
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_VOICE_AGENT"); err == nil && v != "" {
|
||||
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
|
||||
}
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_GSC_MY"); err == nil && v != "" {
|
||||
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
|
||||
}
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_API_KEY_SYNAPSE_HUB"); err == nil && v != "" {
|
||||
c.Auth.APIKeys = append(c.Auth.APIKeys, v)
|
||||
}
|
||||
|
||||
// Asterisk AMI secret
|
||||
if v, err := c.fetchSecret(token, "GSC_OPS_ASTERISK_AMI_SECRET"); err == nil && v != "" {
|
||||
c.Asterisk.AMISecret = v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) fetchSecret(token, secretName string) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v3/secrets/raw/%s?workspaceId=%s&environment=%s&secretPath=%s",
|
||||
c.Infisical.Host, secretName, c.Infisical.ProjectID, c.Infisical.Environment, c.Infisical.SecretPath)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Infisical returned status %d for %s", resp.StatusCode, secretName)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Secret struct {
|
||||
SecretValue string `json:"secretValue"`
|
||||
} `json:"secret"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.Secret.SecretValue, nil
|
||||
}
|
||||
|
||||
// DatabaseDSN returns the legacy database connection string
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
|
||||
c.Database.Host, c.Database.Port, c.Database.Database,
|
||||
c.Database.User, c.Database.Password, c.Database.SSLMode)
|
||||
}
|
||||
|
||||
// NamedDatabaseDSN returns the connection string for a named database
|
||||
func (c *Config) NamedDatabaseDSN(name string) string {
|
||||
db, ok := c.Databases[name]
|
||||
if !ok {
|
||||
return c.DatabaseDSN()
|
||||
}
|
||||
dsn := fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
|
||||
db.Host, db.Port, db.Database,
|
||||
db.User, db.Password, db.SSLMode)
|
||||
if db.Schema != "" {
|
||||
dsn += fmt.Sprintf(" search_path=%s,public", db.Schema)
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// HasDatabase returns true if a named database is configured
|
||||
func (c *Config) HasDatabase(name string) bool {
|
||||
_, ok := c.Databases[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// CardDAVDSN returns the CardDAV database connection string
|
||||
func (c *Config) CardDAVDSN() string {
|
||||
return fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
|
||||
c.CardDAV.Host, c.CardDAV.Port, c.CardDAV.Database,
|
||||
c.CardDAV.User, c.CardDAV.Password, c.CardDAV.SSLMode)
|
||||
}
|
||||
91
internal/database/db.go
Normal file
91
internal/database/db.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
)
|
||||
|
||||
// DB wraps the database connection pool
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new database connection pool using the legacy config
|
||||
func New(cfg *config.Config) (*DB, error) {
|
||||
return NewFromDSN(cfg.DatabaseDSN(), cfg.Database.MaxConns, cfg.Database.MinConns)
|
||||
}
|
||||
|
||||
// NewNamed creates a new database connection pool for a named database
|
||||
func NewNamed(cfg *config.Config, name string) (*DB, error) {
|
||||
db, ok := cfg.Databases[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("database %q not configured", name)
|
||||
}
|
||||
return NewFromDSN(cfg.NamedDatabaseDSN(name), db.MaxConns, db.MinConns)
|
||||
}
|
||||
|
||||
// NewFromDSN creates a new database connection pool from a DSN string
|
||||
func NewFromDSN(dsn string, maxConns, minConns int) (*DB, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse database config: %w", err)
|
||||
}
|
||||
|
||||
poolConfig.MaxConns = int32(maxConns)
|
||||
poolConfig.MinConns = int32(minConns)
|
||||
poolConfig.MaxConnLifetime = 1 * time.Hour
|
||||
poolConfig.MaxConnIdleTime = 30 * time.Minute
|
||||
poolConfig.HealthCheckPeriod = 1 * time.Minute
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() {
|
||||
if db.pool != nil {
|
||||
db.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DB) Pool() *pgxpool.Pool {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
||||
return db.pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
return db.pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||
return db.pool.Exec(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) Health(ctx context.Context) error {
|
||||
return db.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (db *DB) Stats() *pgxpool.Stat {
|
||||
return db.pool.Stat()
|
||||
}
|
||||
330
internal/handler/carddav.go
Normal file
330
internal/handler/carddav.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// CardDAVHandler handles CardDAV endpoints
|
||||
type CardDAVHandler struct {
|
||||
svc *service.CardDAVService
|
||||
}
|
||||
|
||||
// NewCardDAVHandler creates a new CardDAV handler
|
||||
func NewCardDAVHandler(svc *service.CardDAVService) *CardDAVHandler {
|
||||
return &CardDAVHandler{svc: svc}
|
||||
}
|
||||
|
||||
// --- Principals ---
|
||||
|
||||
// ListPrincipals handles GET /api/v1/carddav/principals
|
||||
func (h *CardDAVHandler) ListPrincipals(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
principals, err := h.svc.ListPrincipals(c.Context())
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(principals, reqID))
|
||||
}
|
||||
|
||||
// GetPrincipal handles GET /api/v1/carddav/principals/:username
|
||||
func (h *CardDAVHandler) GetPrincipal(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
username := c.Params("username")
|
||||
|
||||
principal, err := h.svc.GetPrincipal(c.Context(), username)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if principal == nil {
|
||||
apiErr := types.NewNotFound("Principal not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(principal, reqID))
|
||||
}
|
||||
|
||||
// CreatePrincipal handles POST /api/v1/carddav/principals
|
||||
func (h *CardDAVHandler) CreatePrincipal(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.CardDAVPrincipalCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
apiErr := types.NewValidation("username is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
principal, err := h.svc.CreatePrincipal(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(principal, reqID))
|
||||
}
|
||||
|
||||
// DeletePrincipal handles DELETE /api/v1/carddav/principals/:username
|
||||
func (h *CardDAVHandler) DeletePrincipal(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
username := c.Params("username")
|
||||
|
||||
if err := h.svc.DeletePrincipal(c.Context(), username); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"username": username, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// --- Address Books ---
|
||||
|
||||
// ListAddressBooks handles GET /api/v1/carddav/addressbooks
|
||||
func (h *CardDAVHandler) ListAddressBooks(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
principal := c.Query("principal")
|
||||
|
||||
books, err := h.svc.ListAddressBooks(c.Context(), principal)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(books, reqID))
|
||||
}
|
||||
|
||||
// GetAddressBook handles GET /api/v1/carddav/addressbooks/:id
|
||||
func (h *CardDAVHandler) GetAddressBook(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
book, err := h.svc.GetAddressBook(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if book == nil {
|
||||
apiErr := types.NewNotFound("Address book not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(book, reqID))
|
||||
}
|
||||
|
||||
// CreateAddressBook handles POST /api/v1/carddav/addressbooks
|
||||
func (h *CardDAVHandler) CreateAddressBook(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.AddressBookCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.PrincipalURI == "" || req.DisplayName == "" || req.URI == "" {
|
||||
apiErr := types.NewValidation("principalUri, displayName, and uri are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
book, err := h.svc.CreateAddressBook(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(book, reqID))
|
||||
}
|
||||
|
||||
// UpdateAddressBook handles PUT /api/v1/carddav/addressbooks/:id
|
||||
func (h *CardDAVHandler) UpdateAddressBook(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.AddressBookUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
book, err := h.svc.UpdateAddressBook(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if book == nil {
|
||||
apiErr := types.NewNotFound("Address book not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(book, reqID))
|
||||
}
|
||||
|
||||
// DeleteAddressBook handles DELETE /api/v1/carddav/addressbooks/:id
|
||||
func (h *CardDAVHandler) DeleteAddressBook(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAddressBook(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// --- Contacts ---
|
||||
|
||||
// ListContacts handles GET /api/v1/carddav/addressbooks/:id/contacts
|
||||
func (h *CardDAVHandler) ListContacts(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
contacts, err := h.svc.ListContacts(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(contacts, reqID))
|
||||
}
|
||||
|
||||
// GetContact handles GET /api/v1/carddav/addressbooks/:id/contacts/:uri
|
||||
func (h *CardDAVHandler) GetContact(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
uri := c.Params("uri")
|
||||
|
||||
contact, err := h.svc.GetContact(c.Context(), id, uri)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if contact == nil {
|
||||
apiErr := types.NewNotFound("Contact not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(contact, reqID))
|
||||
}
|
||||
|
||||
// CreateContact handles POST /api/v1/carddav/addressbooks/:id/contacts
|
||||
func (h *CardDAVHandler) CreateContact(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.ContactCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.URI == "" || req.CardData == "" {
|
||||
apiErr := types.NewValidation("uri and cardData are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
contact, err := h.svc.CreateContact(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(contact, reqID))
|
||||
}
|
||||
|
||||
// UpdateContact handles PUT /api/v1/carddav/addressbooks/:id/contacts/:uri
|
||||
func (h *CardDAVHandler) UpdateContact(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
uri := c.Params("uri")
|
||||
|
||||
var req types.ContactUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.CardData == "" {
|
||||
apiErr := types.NewValidation("cardData is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
contact, err := h.svc.UpdateContact(c.Context(), id, uri, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if contact == nil {
|
||||
apiErr := types.NewNotFound("Contact not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(contact, reqID))
|
||||
}
|
||||
|
||||
// DeleteContact handles DELETE /api/v1/carddav/addressbooks/:id/contacts/:uri
|
||||
func (h *CardDAVHandler) DeleteContact(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid address book ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
uri := c.Params("uri")
|
||||
|
||||
if err := h.svc.DeleteContact(c.Context(), id, uri); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"addressbookId": id, "uri": uri, "deleted": true}, reqID))
|
||||
}
|
||||
140
internal/handler/certs.go
Normal file
140
internal/handler/certs.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// CertHandler handles certificate endpoints
|
||||
type CertHandler struct {
|
||||
svc *service.CertificateService
|
||||
}
|
||||
|
||||
// NewCertHandler creates a new certificate handler
|
||||
func NewCertHandler(svc *service.CertificateService) *CertHandler {
|
||||
return &CertHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/certs
|
||||
func (h *CertHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
search := c.Query("search")
|
||||
limit := c.QueryInt("limit", 50)
|
||||
|
||||
certs, err := h.svc.ListCertificates(search, limit)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(certs, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/certs/:serialNumber
|
||||
func (h *CertHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
serialNumber := c.Params("serialNumber")
|
||||
issuerDN := c.Query("issuerDn")
|
||||
|
||||
if issuerDN == "" {
|
||||
apiErr := types.NewValidation("issuerDn query parameter is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
cert, err := h.svc.GetCertificate(serialNumber, issuerDN)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(cert, reqID))
|
||||
}
|
||||
|
||||
// Request handles POST /api/v1/certs/request
|
||||
func (h *CertHandler) Request(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.CertRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.SubjectDN == "" || req.CAName == "" || req.CertProfileName == "" || req.EndEntityName == "" {
|
||||
apiErr := types.NewValidation("subjectDn, caName, certProfileName, and endEntityName are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
cert, err := h.svc.RequestCertificate(&req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(cert, reqID))
|
||||
}
|
||||
|
||||
// Renew handles POST /api/v1/certs/:serialNumber/renew
|
||||
func (h *CertHandler) Renew(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
serialNumber := c.Params("serialNumber")
|
||||
|
||||
// For renewal, we re-request with the same parameters
|
||||
// The caller should provide the original cert's issuer DN
|
||||
issuerDN := c.Query("issuerDn")
|
||||
if issuerDN == "" {
|
||||
apiErr := types.NewValidation("issuerDn query parameter is required for renewal")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
// Get the existing cert to extract parameters
|
||||
existing, err := h.svc.GetCertificate(serialNumber, issuerDN)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
// Re-request with same subject
|
||||
cert, err := h.svc.RequestCertificate(&types.CertRequest{
|
||||
SubjectDN: existing.SubjectDN,
|
||||
CAName: existing.CAName,
|
||||
CertProfileName: "SERVER",
|
||||
EndEntityName: existing.SubjectDN,
|
||||
})
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(cert, reqID))
|
||||
}
|
||||
|
||||
// Revoke handles POST /api/v1/certs/:serialNumber/revoke
|
||||
func (h *CertHandler) Revoke(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
serialNumber := c.Params("serialNumber")
|
||||
|
||||
var req types.CertRevoke
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.IssuerDN == "" {
|
||||
apiErr := types.NewValidation("issuerDn is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.RevokeCertificate(serialNumber, &req); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{
|
||||
"serialNumber": serialNumber,
|
||||
"revoked": true,
|
||||
}, reqID))
|
||||
}
|
||||
126
internal/handler/db_tenants.go
Normal file
126
internal/handler/db_tenants.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DBTenantHandler handles database tenant endpoints
|
||||
type DBTenantHandler struct {
|
||||
svc *service.DatabaseService
|
||||
}
|
||||
|
||||
// NewDBTenantHandler creates a new DB tenant handler
|
||||
func NewDBTenantHandler(svc *service.DatabaseService) *DBTenantHandler {
|
||||
return &DBTenantHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/db/tenants
|
||||
func (h *DBTenantHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
Status: c.Query("status"),
|
||||
}
|
||||
|
||||
tenants, total, err := h.svc.ListTenants(c.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(tenants, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/db/tenants/:id
|
||||
func (h *DBTenantHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid tenant ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenant, err := h.svc.GetTenant(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Tenant not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(tenant, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/db/tenants
|
||||
func (h *DBTenantHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.TenantCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Name == "" || req.Code == "" || req.CustomerID == uuid.Nil {
|
||||
apiErr := types.NewValidation("customerId, code, and name are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenant, err := h.svc.CreateTenant(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(tenant, reqID))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/db/tenants/:id
|
||||
func (h *DBTenantHandler) Update(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid tenant ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.TenantUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenant, err := h.svc.UpdateTenant(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(tenant, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/db/tenants/:id (soft delete)
|
||||
func (h *DBTenantHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid tenant ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.SoftDeleteTenant(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
|
||||
}
|
||||
126
internal/handler/db_users.go
Normal file
126
internal/handler/db_users.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DBUserHandler handles database user endpoints
|
||||
type DBUserHandler struct {
|
||||
svc *service.DatabaseService
|
||||
}
|
||||
|
||||
// NewDBUserHandler creates a new DB user handler
|
||||
func NewDBUserHandler(svc *service.DatabaseService) *DBUserHandler {
|
||||
return &DBUserHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/db/users
|
||||
func (h *DBUserHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
Status: c.Query("status"),
|
||||
}
|
||||
|
||||
users, total, err := h.svc.ListUsers(c.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(users, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/db/users/:id
|
||||
func (h *DBUserHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid user ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
user, err := h.svc.GetUser(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("User not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(user, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/db/users
|
||||
func (h *DBUserHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.DBUserCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.GscSID == "" {
|
||||
apiErr := types.NewValidation("gscsid is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
user, err := h.svc.CreateUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(user, reqID))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/db/users/:id
|
||||
func (h *DBUserHandler) Update(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid user ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.DBUserUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
user, err := h.svc.UpdateUser(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(user, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/db/users/:id (deactivate)
|
||||
func (h *DBUserHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid user ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeactivateUser(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deactivated": true}, reqID))
|
||||
}
|
||||
167
internal/handler/dns_records.go
Normal file
167
internal/handler/dns_records.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DNSRecordHandler handles DNS record endpoints
|
||||
type DNSRecordHandler struct {
|
||||
svc *service.DNSService
|
||||
}
|
||||
|
||||
// NewDNSRecordHandler creates a new DNS record handler
|
||||
func NewDNSRecordHandler(svc *service.DNSService) *DNSRecordHandler {
|
||||
return &DNSRecordHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/dns/zones/:zoneId/records
|
||||
func (h *DNSRecordHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
records, err := h.svc.ListRecords(zoneID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(records, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/dns/zones/:zoneId/records
|
||||
func (h *DNSRecordHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
var changes []types.DNSRecordChange
|
||||
if err := c.BodyParser(&changes); err != nil {
|
||||
// Try single change
|
||||
var single types.DNSRecordChange
|
||||
if err2 := c.BodyParser(&single); err2 != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
changes = []types.DNSRecordChange{single}
|
||||
}
|
||||
|
||||
// Set changetype to REPLACE for creates
|
||||
for i := range changes {
|
||||
if changes[i].ChangeType == "" {
|
||||
changes[i].ChangeType = "REPLACE"
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.ChangeRecords(zoneID, changes); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(fiber.Map{
|
||||
"zoneId": zoneID,
|
||||
"changes": len(changes),
|
||||
}, reqID))
|
||||
}
|
||||
|
||||
// Replace handles PUT /api/v1/dns/zones/:zoneId/records
|
||||
func (h *DNSRecordHandler) Replace(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
var changes []types.DNSRecordChange
|
||||
if err := c.BodyParser(&changes); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
for i := range changes {
|
||||
changes[i].ChangeType = "REPLACE"
|
||||
}
|
||||
|
||||
if err := h.svc.ChangeRecords(zoneID, changes); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{
|
||||
"zoneId": zoneID,
|
||||
"replaced": len(changes),
|
||||
}, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/dns/zones/:zoneId/records
|
||||
func (h *DNSRecordHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
var changes []types.DNSRecordChange
|
||||
if err := c.BodyParser(&changes); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
for i := range changes {
|
||||
changes[i].ChangeType = "DELETE"
|
||||
}
|
||||
|
||||
if err := h.svc.ChangeRecords(zoneID, changes); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{
|
||||
"zoneId": zoneID,
|
||||
"deleted": len(changes),
|
||||
}, reqID))
|
||||
}
|
||||
|
||||
// DomainSetup handles POST /api/v1/dns/domains/setup
|
||||
func (h *DNSRecordHandler) DomainSetup(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.DomainSetup
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Domain == "" {
|
||||
apiErr := types.NewValidation("domain is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
zone, err := h.svc.SetupDomain(&req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(zone, reqID))
|
||||
}
|
||||
|
||||
// DomainVerify handles POST /api/v1/dns/domains/verify
|
||||
func (h *DNSRecordHandler) DomainVerify(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.DomainVerify
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Domain == "" {
|
||||
apiErr := types.NewValidation("domain is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
result, err := h.svc.VerifyDomain(req.Domain)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(result, reqID))
|
||||
}
|
||||
115
internal/handler/dns_zones.go
Normal file
115
internal/handler/dns_zones.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DNSZoneHandler handles DNS zone endpoints
|
||||
type DNSZoneHandler struct {
|
||||
svc *service.DNSService
|
||||
}
|
||||
|
||||
// NewDNSZoneHandler creates a new DNS zone handler
|
||||
func NewDNSZoneHandler(svc *service.DNSService) *DNSZoneHandler {
|
||||
return &DNSZoneHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/dns/zones
|
||||
func (h *DNSZoneHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
zones, err := h.svc.ListZones()
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(zones, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/dns/zones/:zoneId
|
||||
func (h *DNSZoneHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
zone, err := h.svc.GetZone(zoneID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(zone, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/dns/zones
|
||||
func (h *DNSZoneHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.DNSZoneCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
apiErr := types.NewValidation("name is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
zone, err := h.svc.CreateZone(&req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(zone, reqID))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/dns/zones/:zoneId
|
||||
func (h *DNSZoneHandler) Update(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
var req types.DNSZoneUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateZone(zoneID, &req); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": zoneID, "updated": true}, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/dns/zones/:zoneId
|
||||
func (h *DNSZoneHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
if err := h.svc.DeleteZone(zoneID); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": zoneID, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// Notify handles POST /api/v1/dns/zones/:zoneId/notify
|
||||
func (h *DNSZoneHandler) Notify(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
zoneID := c.Params("zoneId")
|
||||
|
||||
if err := h.svc.NotifyZone(zoneID); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": zoneID, "notified": true}, reqID))
|
||||
}
|
||||
94
internal/handler/health.go
Normal file
94
internal/handler/health.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/internal/database"
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// HealthHandler handles health check endpoints
|
||||
type HealthHandler struct {
|
||||
db *database.DB
|
||||
ldap *client.LDAPClient
|
||||
pdns *client.PowerDNSClient
|
||||
carddav *client.CardDAVClient
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new health handler
|
||||
func NewHealthHandler(db *database.DB, ldap *client.LDAPClient, pdns *client.PowerDNSClient, carddav *client.CardDAVClient) *HealthHandler {
|
||||
return &HealthHandler{db: db, ldap: ldap, pdns: pdns, carddav: carddav}
|
||||
}
|
||||
|
||||
// Liveness returns 200 if the server is running
|
||||
func (h *HealthHandler) Liveness(c *fiber.Ctx) error {
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC(),
|
||||
}, middleware.GetRequestID(c)))
|
||||
}
|
||||
|
||||
// Readiness checks all backend dependencies
|
||||
func (h *HealthHandler) Readiness(c *fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
checks := make(map[string]string)
|
||||
allOK := true
|
||||
|
||||
// Database
|
||||
if err := h.db.Health(ctx); err != nil {
|
||||
checks["database"] = "error: " + err.Error()
|
||||
allOK = false
|
||||
} else {
|
||||
checks["database"] = "ok"
|
||||
}
|
||||
|
||||
// LDAP
|
||||
if h.ldap != nil {
|
||||
if err := h.ldap.Health(); err != nil {
|
||||
checks["ldap"] = "error: " + err.Error()
|
||||
allOK = false
|
||||
} else {
|
||||
checks["ldap"] = "ok"
|
||||
}
|
||||
}
|
||||
|
||||
// PowerDNS
|
||||
if h.pdns != nil {
|
||||
if err := h.pdns.Health(); err != nil {
|
||||
checks["powerdns"] = "error: " + err.Error()
|
||||
allOK = false
|
||||
} else {
|
||||
checks["powerdns"] = "ok"
|
||||
}
|
||||
}
|
||||
|
||||
// CardDAV
|
||||
if h.carddav != nil {
|
||||
if err := h.carddav.Health(ctx); err != nil {
|
||||
checks["carddav"] = "error: " + err.Error()
|
||||
allOK = false
|
||||
} else {
|
||||
checks["carddav"] = "ok"
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
httpStatus := fiber.StatusOK
|
||||
if !allOK {
|
||||
status = "degraded"
|
||||
httpStatus = fiber.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
return c.Status(httpStatus).JSON(types.NewDataResponse(fiber.Map{
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
"time": time.Now().UTC(),
|
||||
}, middleware.GetRequestID(c)))
|
||||
}
|
||||
178
internal/handler/ldap_entities.go
Normal file
178
internal/handler/ldap_entities.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/schema"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// LDAPEntityHandler handles generic LDAP entity endpoints
|
||||
type LDAPEntityHandler struct {
|
||||
svc *service.LDAPEntityService
|
||||
registry *schema.Registry
|
||||
}
|
||||
|
||||
// NewLDAPEntityHandler creates a new entity handler
|
||||
func NewLDAPEntityHandler(svc *service.LDAPEntityService, registry *schema.Registry) *LDAPEntityHandler {
|
||||
return &LDAPEntityHandler{svc: svc, registry: registry}
|
||||
}
|
||||
|
||||
// ListTypes handles GET /api/v1/ldap/entities — list available entity types
|
||||
func (h *LDAPEntityHandler) ListTypes(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
allTypes := h.registry.AllEntityTypes()
|
||||
result := make([]fiber.Map, 0, len(allTypes))
|
||||
for name, et := range allTypes {
|
||||
result = append(result, fiber.Map{
|
||||
"name": name,
|
||||
"description": et.Description,
|
||||
"rdnAttribute": et.RDNAttribute,
|
||||
"domain": et.Domain,
|
||||
"requiredAttrs": et.RequiredAttrs,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(result, reqID))
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/ldap/entities/:type — list entities of a type
|
||||
func (h *LDAPEntityHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
typeName := c.Params("type")
|
||||
search := c.Query("search")
|
||||
limit := c.QueryInt("limit", 50)
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
if h.registry.GetEntityType(typeName) == nil {
|
||||
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
entities, err := h.svc.ListEntities(typeName, search, limit)
|
||||
if err != nil {
|
||||
apiErr := classifyAPIError(err)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(entities, int64(len(entities)), limit, 0, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/ldap/entities/:type/:rdn — get a single entity
|
||||
func (h *LDAPEntityHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
typeName := c.Params("type")
|
||||
rdn := c.Params("rdn")
|
||||
|
||||
if h.registry.GetEntityType(typeName) == nil {
|
||||
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
entity, err := h.svc.GetEntity(typeName, rdn)
|
||||
if err != nil {
|
||||
apiErr := classifyAPIError(err)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if entity == nil {
|
||||
apiErr := types.NewNotFound("Entity not found: " + rdn)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(entity, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/ldap/entities/:type — create an entity
|
||||
func (h *LDAPEntityHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
typeName := c.Params("type")
|
||||
|
||||
if h.registry.GetEntityType(typeName) == nil {
|
||||
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.LDAPEntityCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if len(req.Attributes) == 0 {
|
||||
apiErr := types.NewValidation("attributes are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
entity, err := h.svc.CreateEntity(typeName, &req)
|
||||
if err != nil {
|
||||
apiErr := classifyAPIError(err)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(entity, reqID))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/ldap/entities/:type/:rdn — update an entity
|
||||
func (h *LDAPEntityHandler) Update(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
typeName := c.Params("type")
|
||||
rdn := c.Params("rdn")
|
||||
|
||||
if h.registry.GetEntityType(typeName) == nil {
|
||||
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.LDAPEntityUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
entity, err := h.svc.UpdateEntity(typeName, rdn, &req)
|
||||
if err != nil {
|
||||
apiErr := classifyAPIError(err)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(entity, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/ldap/entities/:type/:rdn — delete an entity
|
||||
func (h *LDAPEntityHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
typeName := c.Params("type")
|
||||
rdn := c.Params("rdn")
|
||||
|
||||
if h.registry.GetEntityType(typeName) == nil {
|
||||
apiErr := types.NewBadRequest("Unknown entity type: " + typeName)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteEntity(typeName, rdn); err != nil {
|
||||
apiErr := classifyAPIError(err)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"type": typeName, "rdn": rdn, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// classifyAPIError maps service errors to appropriate HTTP error responses
|
||||
func classifyAPIError(err error) *types.APIError {
|
||||
kind, msg := service.ClassifyError(err)
|
||||
switch kind {
|
||||
case "conflict":
|
||||
return types.NewConflict(msg)
|
||||
case "not_found":
|
||||
return types.NewNotFound(msg)
|
||||
case "validation":
|
||||
return types.NewValidation(msg)
|
||||
default:
|
||||
return types.NewInternal(msg)
|
||||
}
|
||||
}
|
||||
168
internal/handler/ldap_groups.go
Normal file
168
internal/handler/ldap_groups.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// LDAPGroupHandler handles LDAP group endpoints
|
||||
type LDAPGroupHandler struct {
|
||||
svc *service.LDAPService
|
||||
}
|
||||
|
||||
// NewLDAPGroupHandler creates a new LDAP group handler
|
||||
func NewLDAPGroupHandler(svc *service.LDAPService) *LDAPGroupHandler {
|
||||
return &LDAPGroupHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/ldap/groups
|
||||
func (h *LDAPGroupHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
search := c.Query("search")
|
||||
limit := c.QueryInt("limit", 50)
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
groups, err := h.svc.ListGroups(search, limit)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(groups, int64(len(groups)), limit, 0, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/ldap/groups/:cn
|
||||
func (h *LDAPGroupHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
cn := c.Params("cn")
|
||||
|
||||
group, err := h.svc.GetGroup(cn)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if group == nil {
|
||||
apiErr := types.NewNotFound("Group not found: " + cn)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(group, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/ldap/groups
|
||||
func (h *LDAPGroupHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.LDAPGroupCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.CN == "" {
|
||||
apiErr := types.NewValidation("cn is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
group, err := h.svc.CreateGroup(&req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(group, reqID))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/ldap/groups/:cn
|
||||
func (h *LDAPGroupHandler) Update(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
cn := c.Params("cn")
|
||||
|
||||
var req types.LDAPGroupUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
group, err := h.svc.UpdateGroup(cn, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(group, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/ldap/groups/:cn
|
||||
func (h *LDAPGroupHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
cn := c.Params("cn")
|
||||
|
||||
if err := h.svc.DeleteGroup(cn); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"cn": cn, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// ListMembers handles GET /api/v1/ldap/groups/:cn/members
|
||||
func (h *LDAPGroupHandler) ListMembers(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
cn := c.Params("cn")
|
||||
|
||||
members, err := h.svc.GetGroupMembers(cn)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if members == nil {
|
||||
apiErr := types.NewNotFound("Group not found: " + cn)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(members, reqID))
|
||||
}
|
||||
|
||||
// AddMembers handles POST /api/v1/ldap/groups/:cn/members
|
||||
func (h *LDAPGroupHandler) AddMembers(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
cn := c.Params("cn")
|
||||
|
||||
var req types.LDAPGroupMemberAdd
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if len(req.Members) == 0 {
|
||||
apiErr := types.NewValidation("members array is required and must not be empty")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.AddGroupMembers(cn, req.Members); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"cn": cn, "added": req.Members}, reqID))
|
||||
}
|
||||
|
||||
// RemoveMember handles DELETE /api/v1/ldap/groups/:cn/members/:uid
|
||||
func (h *LDAPGroupHandler) RemoveMember(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
cn := c.Params("cn")
|
||||
uid := c.Params("uid")
|
||||
|
||||
if err := h.svc.RemoveGroupMember(cn, uid); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"cn": cn, "removed": uid}, reqID))
|
||||
}
|
||||
215
internal/handler/ldap_users.go
Normal file
215
internal/handler/ldap_users.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// LDAPUserHandler handles LDAP user endpoints
|
||||
type LDAPUserHandler struct {
|
||||
svc *service.LDAPService
|
||||
}
|
||||
|
||||
// NewLDAPUserHandler creates a new LDAP user handler
|
||||
func NewLDAPUserHandler(svc *service.LDAPService) *LDAPUserHandler {
|
||||
return &LDAPUserHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/ldap/users
|
||||
//
|
||||
// Query parameters:
|
||||
// - search: free-text search across uid, givenName, sn, mail
|
||||
// - services: comma-separated service domains (mail,calendar)
|
||||
// - attr.<ldapAttr>: filter by any LDAP attribute (e.g. attr.gscTenantId=abc123)
|
||||
// - limit: max results (default 50, max 500)
|
||||
func (h *LDAPUserHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
search := c.Query("search")
|
||||
limit := c.QueryInt("limit", 50)
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
// Parse services filter: ?services=mail,calendar
|
||||
var serviceFilters []string
|
||||
if svcParam := c.Query("services"); svcParam != "" {
|
||||
serviceFilters = strings.Split(svcParam, ",")
|
||||
}
|
||||
|
||||
// Parse dynamic attribute filters: ?attr.gscTenantId=abc&attr.mail=*@example.com
|
||||
attrFilters := make(map[string]string)
|
||||
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
|
||||
k := string(key)
|
||||
if strings.HasPrefix(k, "attr.") && len(k) > 5 {
|
||||
attrName := k[5:]
|
||||
attrFilters[attrName] = string(value)
|
||||
}
|
||||
})
|
||||
|
||||
users, err := h.svc.ListUsers(search, limit, serviceFilters, attrFilters)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(users, int64(len(users)), limit, 0, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/ldap/users/:uid
|
||||
func (h *LDAPUserHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
|
||||
user, err := h.svc.GetUser(uid)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if user == nil {
|
||||
apiErr := types.NewNotFound("User not found: " + uid)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(user, reqID))
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/ldap/users
|
||||
func (h *LDAPUserHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.LDAPUserCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.UID == "" || req.FirstName == "" || req.LastName == "" {
|
||||
apiErr := types.NewValidation("uid, firstName, and lastName are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
user, err := h.svc.CreateUser(&req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(user, reqID))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/ldap/users/:uid
|
||||
func (h *LDAPUserHandler) Update(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
|
||||
var req types.LDAPUserUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
user, err := h.svc.UpdateUser(uid, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(user, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/ldap/users/:uid (disables the user)
|
||||
func (h *LDAPUserHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
|
||||
if err := h.svc.DisableUser(uid); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"uid": uid, "disabled": true}, reqID))
|
||||
}
|
||||
|
||||
// ResetPassword handles POST /api/v1/ldap/users/:uid/password
|
||||
func (h *LDAPUserHandler) ResetPassword(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
|
||||
var req types.PasswordReset
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.NewPassword == "" {
|
||||
apiErr := types.NewValidation("newPassword is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.ResetPassword(uid, req.NewPassword); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"uid": uid, "passwordReset": true}, reqID))
|
||||
}
|
||||
|
||||
// ListGroups handles GET /api/v1/ldap/users/:uid/groups
|
||||
func (h *LDAPUserHandler) ListGroups(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
|
||||
groups, err := h.svc.GetUserGroups(uid)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if groups == nil {
|
||||
apiErr := types.NewNotFound("User not found: " + uid)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(groups, reqID))
|
||||
}
|
||||
|
||||
// ListServices handles GET /api/v1/ldap/users/:uid/services
|
||||
func (h *LDAPUserHandler) ListServices(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
|
||||
services, err := h.svc.GetUserServices(uid, "")
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if services == nil {
|
||||
apiErr := types.NewNotFound("User not found: " + uid)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(services, reqID))
|
||||
}
|
||||
|
||||
// GetService handles GET /api/v1/ldap/users/:uid/services/:domain
|
||||
func (h *LDAPUserHandler) GetService(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
uid := c.Params("uid")
|
||||
domain := c.Params("domain")
|
||||
|
||||
services, err := h.svc.GetUserServices(uid, domain)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if services == nil {
|
||||
apiErr := types.NewNotFound("User not found: " + uid)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(services, reqID))
|
||||
}
|
||||
603
internal/handler/pbx.go
Normal file
603
internal/handler/pbx.go
Normal file
@@ -0,0 +1,603 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PBXHandler handles PBX management endpoints
|
||||
type PBXHandler struct {
|
||||
svc *service.PBXService
|
||||
}
|
||||
|
||||
// NewPBXHandler creates a new PBX handler
|
||||
func NewPBXHandler(svc *service.PBXService) *PBXHandler {
|
||||
return &PBXHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trunks
|
||||
// ============================================================================
|
||||
|
||||
// ListTrunks handles GET /api/v1/pbx/trunks
|
||||
func (h *PBXHandler) ListTrunks(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
Status: c.Query("status"),
|
||||
}
|
||||
|
||||
trunks, total, err := h.svc.ListTrunks(c.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(trunks, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetTrunk handles GET /api/v1/pbx/trunks/:id
|
||||
func (h *PBXHandler) GetTrunk(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
trunk, err := h.svc.GetTrunk(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Trunk not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(trunk, reqID))
|
||||
}
|
||||
|
||||
// CreateTrunk handles POST /api/v1/pbx/trunks
|
||||
func (h *PBXHandler) CreateTrunk(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.PBXTrunkCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Name == "" || req.Host == "" || req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId, name, and host are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
trunk, err := h.svc.CreateTrunk(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(trunk, reqID))
|
||||
}
|
||||
|
||||
// UpdateTrunk handles PUT /api/v1/pbx/trunks/:id
|
||||
func (h *PBXHandler) UpdateTrunk(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.PBXTrunkUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
trunk, err := h.svc.UpdateTrunk(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(trunk, reqID))
|
||||
}
|
||||
|
||||
// DeleteTrunk handles DELETE /api/v1/pbx/trunks/:id
|
||||
func (h *PBXHandler) DeleteTrunk(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteTrunk(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// ActivateTrunk handles POST /api/v1/pbx/trunks/:id/activate
|
||||
func (h *PBXHandler) ActivateTrunk(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
trunk, err := h.svc.ActivateTrunk(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(trunk, reqID))
|
||||
}
|
||||
|
||||
// DeactivateTrunk handles POST /api/v1/pbx/trunks/:id/deactivate
|
||||
func (h *PBXHandler) DeactivateTrunk(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
trunk, err := h.svc.DeactivateTrunk(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(trunk, reqID))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trunk DIDs
|
||||
// ============================================================================
|
||||
|
||||
// ListTrunkDIDs handles GET /api/v1/pbx/trunks/:id/dids
|
||||
func (h *PBXHandler) ListTrunkDIDs(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
trunkID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
dids, err := h.svc.ListTrunkDIDs(c.Context(), trunkID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(dids, reqID))
|
||||
}
|
||||
|
||||
// CreateTrunkDID handles POST /api/v1/pbx/trunks/:id/dids
|
||||
func (h *PBXHandler) CreateTrunkDID(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
trunkID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.PBXTrunkDIDCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.DIDNumber == "" || req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId and didNumber are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
did, err := h.svc.CreateTrunkDID(c.Context(), trunkID, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(did, reqID))
|
||||
}
|
||||
|
||||
// DeleteTrunkDID handles DELETE /api/v1/pbx/trunks/:id/dids/:didId
|
||||
func (h *PBXHandler) DeleteTrunkDID(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
trunkID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid trunk ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
didID, err := uuid.Parse(c.Params("didId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid DID ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteTrunkDID(c.Context(), trunkID, didID); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": didID, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extensions
|
||||
// ============================================================================
|
||||
|
||||
// ListExtensions handles GET /api/v1/pbx/extensions
|
||||
func (h *PBXHandler) ListExtensions(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
Status: c.Query("status"),
|
||||
}
|
||||
|
||||
exts, total, err := h.svc.ListExtensions(c.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(exts, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetExtension handles GET /api/v1/pbx/extensions/:id
|
||||
func (h *PBXHandler) GetExtension(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid extension ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
ext, err := h.svc.GetExtension(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Extension not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(ext, reqID))
|
||||
}
|
||||
|
||||
// CreateExtension handles POST /api/v1/pbx/extensions
|
||||
func (h *PBXHandler) CreateExtension(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.PBXExtensionCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Extension == "" || req.Name == "" || req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId, extension, and name are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
ext, err := h.svc.CreateExtension(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(ext, reqID))
|
||||
}
|
||||
|
||||
// UpdateExtension handles PUT /api/v1/pbx/extensions/:id
|
||||
func (h *PBXHandler) UpdateExtension(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid extension ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.PBXExtensionUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
ext, err := h.svc.UpdateExtension(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(ext, reqID))
|
||||
}
|
||||
|
||||
// DeleteExtension handles DELETE /api/v1/pbx/extensions/:id
|
||||
func (h *PBXHandler) DeleteExtension(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid extension ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteExtension(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inbound Routes
|
||||
// ============================================================================
|
||||
|
||||
// ListInboundRoutes handles GET /api/v1/pbx/inbound-routes
|
||||
func (h *PBXHandler) ListInboundRoutes(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
|
||||
routes, total, err := h.svc.ListInboundRoutes(c.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(routes, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetInboundRoute handles GET /api/v1/pbx/inbound-routes/:id
|
||||
func (h *PBXHandler) GetInboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid route ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
route, err := h.svc.GetInboundRoute(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Inbound route not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(route, reqID))
|
||||
}
|
||||
|
||||
// CreateInboundRoute handles POST /api/v1/pbx/inbound-routes
|
||||
func (h *PBXHandler) CreateInboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.PBXInboundRouteCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Name == "" || req.DestinationType == "" || req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId, name, and destinationType are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
route, err := h.svc.CreateInboundRoute(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(route, reqID))
|
||||
}
|
||||
|
||||
// UpdateInboundRoute handles PUT /api/v1/pbx/inbound-routes/:id
|
||||
func (h *PBXHandler) UpdateInboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid route ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.PBXInboundRouteUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
route, err := h.svc.UpdateInboundRoute(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(route, reqID))
|
||||
}
|
||||
|
||||
// DeleteInboundRoute handles DELETE /api/v1/pbx/inbound-routes/:id
|
||||
func (h *PBXHandler) DeleteInboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid route ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteInboundRoute(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Outbound Routes
|
||||
// ============================================================================
|
||||
|
||||
// ListOutboundRoutes handles GET /api/v1/pbx/outbound-routes
|
||||
func (h *PBXHandler) ListOutboundRoutes(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
|
||||
routes, total, err := h.svc.ListOutboundRoutes(c.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(routes, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetOutboundRoute handles GET /api/v1/pbx/outbound-routes/:id
|
||||
func (h *PBXHandler) GetOutboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid route ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
route, err := h.svc.GetOutboundRoute(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Outbound route not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(route, reqID))
|
||||
}
|
||||
|
||||
// CreateOutboundRoute handles POST /api/v1/pbx/outbound-routes
|
||||
func (h *PBXHandler) CreateOutboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.PBXOutboundRouteCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.Name == "" || len(req.DialPatterns) == 0 || req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId, name, and dialPatterns are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
route, err := h.svc.CreateOutboundRoute(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(route, reqID))
|
||||
}
|
||||
|
||||
// UpdateOutboundRoute handles PUT /api/v1/pbx/outbound-routes/:id
|
||||
func (h *PBXHandler) UpdateOutboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid route ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.PBXOutboundRouteUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
route, err := h.svc.UpdateOutboundRoute(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(route, reqID))
|
||||
}
|
||||
|
||||
// DeleteOutboundRoute handles DELETE /api/v1/pbx/outbound-routes/:id
|
||||
func (h *PBXHandler) DeleteOutboundRoute(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid route ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteOutboundRoute(c.Context(), id); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{"id": id, "deleted": true}, reqID))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Operations
|
||||
// ============================================================================
|
||||
|
||||
// Status handles GET /api/v1/pbx/status
|
||||
func (h *PBXHandler) Status(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
status, err := h.svc.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(status, reqID))
|
||||
}
|
||||
|
||||
// Reload handles POST /api/v1/pbx/reload
|
||||
func (h *PBXHandler) Reload(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
result, err := h.svc.Reload(c.Context())
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(result, reqID))
|
||||
}
|
||||
259
internal/handler/persona.go
Normal file
259
internal/handler/persona.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PersonaHandler handles persona management endpoints
|
||||
type PersonaHandler struct {
|
||||
svc *service.PersonaService
|
||||
}
|
||||
|
||||
// NewPersonaHandler creates a new persona handler
|
||||
func NewPersonaHandler(svc *service.PersonaService) *PersonaHandler {
|
||||
return &PersonaHandler{svc: svc}
|
||||
}
|
||||
|
||||
// parseTenantID extracts and validates tenantId from query or body
|
||||
func parseTenantID(c *fiber.Ctx) (uuid.UUID, *types.APIError) {
|
||||
tid := c.Query("tenantId")
|
||||
if tid == "" {
|
||||
return uuid.Nil, types.NewBadRequest("tenantId query parameter is required")
|
||||
}
|
||||
parsed, err := uuid.Parse(tid)
|
||||
if err != nil {
|
||||
return uuid.Nil, types.NewBadRequest("Invalid tenantId")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// ListPersonas handles GET /api/v1/personas
|
||||
func (h *PersonaHandler) ListPersonas(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Status: c.Query("status"),
|
||||
}
|
||||
|
||||
personas, total, err := h.svc.ListPersonas(c.Context(), tenantID, params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(personas, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetPersona handles GET /api/v1/personas/:id
|
||||
func (h *PersonaHandler) GetPersona(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid persona ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
persona, err := h.svc.GetPersona(c.Context(), id, tenantID)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Persona not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(persona, reqID))
|
||||
}
|
||||
|
||||
// CreatePersona handles POST /api/v1/personas
|
||||
func (h *PersonaHandler) CreatePersona(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.PersonaCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if req.Name == "" {
|
||||
apiErr := types.NewValidation("name is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
persona, err := h.svc.CreatePersona(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(persona, reqID))
|
||||
}
|
||||
|
||||
// UpdatePersona handles PUT /api/v1/personas/:id
|
||||
func (h *PersonaHandler) UpdatePersona(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid persona ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.PersonaUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
persona, err := h.svc.UpdatePersona(c.Context(), id, tenantID, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(persona, reqID))
|
||||
}
|
||||
|
||||
// DeletePersona handles DELETE /api/v1/personas/:id
|
||||
func (h *PersonaHandler) DeletePersona(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid persona ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeletePersona(c.Context(), id, tenantID); err != nil {
|
||||
apiErr := types.NewNotFound("Persona not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetSelfModel handles GET /api/v1/personas/:id/self-model
|
||||
func (h *PersonaHandler) GetSelfModel(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid persona ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
snapshot, err := h.svc.GetSelfModel(c.Context(), id, tenantID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(snapshot, reqID))
|
||||
}
|
||||
|
||||
// GetExperiences handles GET /api/v1/personas/:id/experiences
|
||||
func (h *PersonaHandler) GetExperiences(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid persona ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 20)
|
||||
|
||||
experiences, err := h.svc.SearchExperiences(c.Context(), id, tenantID, limit)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(experiences, reqID))
|
||||
}
|
||||
|
||||
// GetEvaluations handles GET /api/v1/personas/:id/evaluations/:sessionId
|
||||
func (h *PersonaHandler) GetEvaluations(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
sessionID, err := uuid.Parse(c.Params("sessionId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid session ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 10)
|
||||
|
||||
evaluations, err := h.svc.GetEvaluations(c.Context(), sessionID, limit)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(evaluations, reqID))
|
||||
}
|
||||
|
||||
// GetMoralPattern handles GET /api/v1/personas/:id/moral-pattern/:sessionId
|
||||
func (h *PersonaHandler) GetMoralPattern(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
sessionID, err := uuid.Parse(c.Params("sessionId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid session ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, apiErr := parseTenantID(c)
|
||||
if apiErr != nil {
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
assessments, err := h.svc.GetMoralPattern(c.Context(), sessionID, tenantID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(map[string]interface{}{
|
||||
"assessments": assessments,
|
||||
}, reqID))
|
||||
}
|
||||
98
internal/handler/personal_agent.go
Normal file
98
internal/handler/personal_agent.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PersonalAgentHandler handles personal agent config endpoints
|
||||
type PersonalAgentHandler struct {
|
||||
svc *service.PersonalAgentService
|
||||
}
|
||||
|
||||
// NewPersonalAgentHandler creates a new personal agent handler
|
||||
func NewPersonalAgentHandler(svc *service.PersonalAgentService) *PersonalAgentHandler {
|
||||
return &PersonalAgentHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GetMyConfig handles GET /api/v1/agents/me?userId=X&tenantId=Y
|
||||
func (h *PersonalAgentHandler) GetMyConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
userID, err := uuid.Parse(c.Query("userId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid or missing userId")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(c.Query("tenantId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid or missing tenantId")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
config, err := h.svc.GetConfig(c.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Personal agent config not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(config, reqID))
|
||||
}
|
||||
|
||||
// UpsertMyConfig handles PUT /api/v1/agents/me
|
||||
func (h *PersonalAgentHandler) UpsertMyConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.UserAgentConfigUpsert
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.UserID == uuid.Nil || req.TenantID == uuid.Nil {
|
||||
apiErr := types.NewValidation("userId and tenantId are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if len(req.Config) == 0 {
|
||||
apiErr := types.NewValidation("config is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
config, err := h.svc.UpsertConfig(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(config, reqID))
|
||||
}
|
||||
|
||||
// DeleteMyConfig handles DELETE /api/v1/agents/me?userId=X&tenantId=Y
|
||||
func (h *PersonalAgentHandler) DeleteMyConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
userID, err := uuid.Parse(c.Query("userId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid or missing userId")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(c.Query("tenantId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid or missing tenantId")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteConfig(c.Context(), userID, tenantID); err != nil {
|
||||
apiErr := types.NewNotFound("Personal agent config not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
100
internal/handler/pgp.go
Normal file
100
internal/handler/pgp.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PGPHandler handles PGP key endpoints
|
||||
type PGPHandler struct {
|
||||
svc *service.PGPService
|
||||
}
|
||||
|
||||
// NewPGPHandler creates a new PGP handler
|
||||
func NewPGPHandler(svc *service.PGPService) *PGPHandler {
|
||||
return &PGPHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Search handles GET /api/v1/pgp/keys
|
||||
func (h *PGPHandler) Search(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
query := c.Query("search")
|
||||
if query == "" {
|
||||
query = c.Query("q")
|
||||
}
|
||||
|
||||
if query == "" {
|
||||
apiErr := types.NewValidation("search or q query parameter is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
keys, err := h.svc.SearchKeys(query)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(keys, reqID))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/pgp/keys/:keyId
|
||||
func (h *PGPHandler) Get(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
keyID := c.Params("keyId")
|
||||
|
||||
key, err := h.svc.GetKey(keyID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if key == nil {
|
||||
apiErr := types.NewNotFound("PGP key not found: " + keyID)
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(key, reqID))
|
||||
}
|
||||
|
||||
// Upload handles POST /api/v1/pgp/keys
|
||||
func (h *PGPHandler) Upload(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.PGPKeyUpload
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.KeyText == "" {
|
||||
apiErr := types.NewValidation("keyText is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.UploadKey(req.KeyText); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(fiber.Map{
|
||||
"uploaded": true,
|
||||
}, reqID))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/pgp/keys/:keyId
|
||||
func (h *PGPHandler) Delete(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
keyID := c.Params("keyId")
|
||||
|
||||
if err := h.svc.DeleteKey(keyID); err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(fiber.Map{
|
||||
"keyId": keyID,
|
||||
"deleted": true,
|
||||
}, reqID))
|
||||
}
|
||||
186
internal/handler/voice_agent.go
Normal file
186
internal/handler/voice_agent.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// VoiceAgentHandler handles voice agent management endpoints
|
||||
type VoiceAgentHandler struct {
|
||||
svc *service.VoiceAgentService
|
||||
}
|
||||
|
||||
// NewVoiceAgentHandler creates a new voice agent handler
|
||||
func NewVoiceAgentHandler(svc *service.VoiceAgentService) *VoiceAgentHandler {
|
||||
return &VoiceAgentHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice Agent Configs
|
||||
// ============================================================================
|
||||
|
||||
// ListConfigs handles GET /api/v1/voice-agents
|
||||
func (h *VoiceAgentHandler) ListConfigs(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
|
||||
var tenantID *uuid.UUID
|
||||
if tid := c.Query("tenantId"); tid != "" {
|
||||
parsed, err := uuid.Parse(tid)
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid tenantId")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
tenantID = &parsed
|
||||
}
|
||||
|
||||
configs, total, err := h.svc.ListConfigs(c.Context(), params, tenantID)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(configs, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetConfig handles GET /api/v1/voice-agents/:id
|
||||
func (h *VoiceAgentHandler) GetConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid voice agent config ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
config, err := h.svc.GetConfig(c.Context(), id)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Voice agent config not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(config, reqID))
|
||||
}
|
||||
|
||||
// CreateConfig handles POST /api/v1/voice-agents
|
||||
func (h *VoiceAgentHandler) CreateConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.VoiceAgentConfigCreate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if req.TenantID == uuid.Nil || req.AgentID == uuid.Nil {
|
||||
apiErr := types.NewValidation("tenantId and agentId are required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
config, err := h.svc.CreateConfig(c.Context(), &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(config, reqID))
|
||||
}
|
||||
|
||||
// UpdateConfig handles PUT /api/v1/voice-agents/:id
|
||||
func (h *VoiceAgentHandler) UpdateConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid voice agent config ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
var req types.VoiceAgentConfigUpdate
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
config, err := h.svc.UpdateConfig(c.Context(), id, &req)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(config, reqID))
|
||||
}
|
||||
|
||||
// DeleteConfig handles DELETE /api/v1/voice-agents/:id
|
||||
func (h *VoiceAgentHandler) DeleteConfig(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid voice agent config ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteConfig(c.Context(), id); err != nil {
|
||||
apiErr := types.NewNotFound("Voice agent config not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice Sessions
|
||||
// ============================================================================
|
||||
|
||||
// ListSessions handles GET /api/v1/voice-agents/:id/sessions
|
||||
func (h *VoiceAgentHandler) ListSessions(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
agentID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid voice agent config ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
params := types.ListParams{
|
||||
Limit: c.QueryInt("limit", 50),
|
||||
Offset: c.QueryInt("offset", 0),
|
||||
}
|
||||
|
||||
sessions, total, err := h.svc.ListSessions(c.Context(), agentID, params)
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(sessions, total, params.Limit, params.Offset, reqID))
|
||||
}
|
||||
|
||||
// GetSession handles GET /api/v1/voice-agents/sessions/:sessionId
|
||||
func (h *VoiceAgentHandler) GetSession(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
sessionID, err := uuid.Parse(c.Params("sessionId"))
|
||||
if err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid session ID")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
session, err := h.svc.GetSession(c.Context(), sessionID)
|
||||
if err != nil {
|
||||
apiErr := types.NewNotFound("Voice session not found")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewDataResponse(session, reqID))
|
||||
}
|
||||
37
internal/middleware/apikey.go
Normal file
37
internal/middleware/apikey.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
const APIKeyHeader = "X-API-Key"
|
||||
|
||||
// APIKey validates the X-API-Key header against configured keys
|
||||
func APIKey(validKeys []string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
key := c.Get(APIKeyHeader)
|
||||
if key == "" {
|
||||
apiErr := types.NewUnauthorized("Missing API key")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c)))
|
||||
}
|
||||
|
||||
valid := false
|
||||
for _, vk := range validKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !valid {
|
||||
apiErr := types.NewUnauthorized("Invalid API key")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c)))
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
68
internal/middleware/jwt.go
Normal file
68
internal/middleware/jwt.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTClaims contains extracted claims from the JWT
|
||||
type JWTClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
TenantID string `json:"tenantId"`
|
||||
}
|
||||
|
||||
// JWTExtract extracts JWT claims from the Authorization header for audit context.
|
||||
// This middleware does NOT validate the JWT — it only extracts claims.
|
||||
// Authentication is handled by mTLS + API key. JWT is optional passthrough.
|
||||
func JWTExtract() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
auth := c.Get("Authorization")
|
||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
||||
|
||||
// Parse without validation — we trust the API key for auth
|
||||
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
||||
token, _, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
// Invalid JWT — ignore, not blocking
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
jwtClaims := &JWTClaims{}
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
jwtClaims.Subject = sub
|
||||
}
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
jwtClaims.Email = email
|
||||
}
|
||||
if name, ok := claims["name"].(string); ok {
|
||||
jwtClaims.Name = name
|
||||
}
|
||||
if tid, ok := claims["tenantId"].(string); ok {
|
||||
jwtClaims.TenantID = tid
|
||||
}
|
||||
|
||||
c.Locals("jwtClaims", jwtClaims)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetJWTClaims retrieves JWT claims from context
|
||||
func GetJWTClaims(c *fiber.Ctx) *JWTClaims {
|
||||
if claims, ok := c.Locals("jwtClaims").(*JWTClaims); ok {
|
||||
return claims
|
||||
}
|
||||
return nil
|
||||
}
|
||||
39
internal/middleware/logging.go
Normal file
39
internal/middleware/logging.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Logging provides structured request logging via zerolog
|
||||
func Logging(logger zerolog.Logger) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
|
||||
err := c.Next()
|
||||
|
||||
duration := time.Since(start)
|
||||
status := c.Response().StatusCode()
|
||||
|
||||
event := logger.Info()
|
||||
if status >= 500 {
|
||||
event = logger.Error()
|
||||
} else if status >= 400 {
|
||||
event = logger.Warn()
|
||||
}
|
||||
|
||||
event.
|
||||
Str("method", c.Method()).
|
||||
Str("path", c.Path()).
|
||||
Int("status", status).
|
||||
Dur("duration", duration).
|
||||
Str("requestId", GetRequestID(c)).
|
||||
Str("ip", c.IP()).
|
||||
Str("userAgent", c.Get("User-Agent")).
|
||||
Msg("request")
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
29
internal/middleware/requestid.go
Normal file
29
internal/middleware/requestid.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// RequestID generates or extracts a request ID for each request
|
||||
func RequestID() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
reqID := c.Get(RequestIDHeader)
|
||||
if reqID == "" {
|
||||
reqID = uuid.New().String()
|
||||
}
|
||||
c.Locals("requestId", reqID)
|
||||
c.Set(RequestIDHeader, reqID)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestID extracts the request ID from context
|
||||
func GetRequestID(c *fiber.Ctx) string {
|
||||
if id, ok := c.Locals("requestId").(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
225
internal/router/router.go
Normal file
225
internal/router/router.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/handler"
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
)
|
||||
|
||||
// Config holds all handler dependencies for route registration
|
||||
type Config struct {
|
||||
Logger zerolog.Logger
|
||||
APIKeys []string
|
||||
Health *handler.HealthHandler
|
||||
LDAPUsers *handler.LDAPUserHandler
|
||||
LDAPGroups *handler.LDAPGroupHandler
|
||||
LDAPEntities *handler.LDAPEntityHandler
|
||||
DNSZones *handler.DNSZoneHandler
|
||||
DNSRecords *handler.DNSRecordHandler
|
||||
DBTenants *handler.DBTenantHandler
|
||||
DBUsers *handler.DBUserHandler
|
||||
Certs *handler.CertHandler
|
||||
PGP *handler.PGPHandler
|
||||
CardDAV *handler.CardDAVHandler
|
||||
PBX *handler.PBXHandler
|
||||
VoiceAgent *handler.VoiceAgentHandler
|
||||
PersonalAgent *handler.PersonalAgentHandler
|
||||
Persona *handler.PersonaHandler
|
||||
}
|
||||
|
||||
// Setup registers all routes on the Fiber app
|
||||
func Setup(app *fiber.App, cfg *Config) {
|
||||
// Global middleware
|
||||
app.Use(recover.New())
|
||||
app.Use(middleware.RequestID())
|
||||
app.Use(middleware.Logging(cfg.Logger))
|
||||
app.Use(middleware.JWTExtract())
|
||||
|
||||
// Health endpoints (no API key required)
|
||||
app.Get("/health", cfg.Health.Liveness)
|
||||
app.Get("/ready", cfg.Health.Readiness)
|
||||
|
||||
// API v1 routes (API key required)
|
||||
api := app.Group("/api/v1", middleware.APIKey(cfg.APIKeys))
|
||||
|
||||
// LDAP Users
|
||||
ldapUsers := api.Group("/ldap/users")
|
||||
ldapUsers.Get("/", cfg.LDAPUsers.List)
|
||||
ldapUsers.Get("/:uid", cfg.LDAPUsers.Get)
|
||||
ldapUsers.Post("/", cfg.LDAPUsers.Create)
|
||||
ldapUsers.Put("/:uid", cfg.LDAPUsers.Update)
|
||||
ldapUsers.Delete("/:uid", cfg.LDAPUsers.Delete)
|
||||
ldapUsers.Post("/:uid/password", cfg.LDAPUsers.ResetPassword)
|
||||
ldapUsers.Get("/:uid/groups", cfg.LDAPUsers.ListGroups)
|
||||
ldapUsers.Get("/:uid/services", cfg.LDAPUsers.ListServices)
|
||||
ldapUsers.Get("/:uid/services/:domain", cfg.LDAPUsers.GetService)
|
||||
|
||||
// LDAP Groups
|
||||
ldapGroups := api.Group("/ldap/groups")
|
||||
ldapGroups.Get("/", cfg.LDAPGroups.List)
|
||||
ldapGroups.Get("/:cn", cfg.LDAPGroups.Get)
|
||||
ldapGroups.Post("/", cfg.LDAPGroups.Create)
|
||||
ldapGroups.Put("/:cn", cfg.LDAPGroups.Update)
|
||||
ldapGroups.Delete("/:cn", cfg.LDAPGroups.Delete)
|
||||
ldapGroups.Get("/:cn/members", cfg.LDAPGroups.ListMembers)
|
||||
ldapGroups.Post("/:cn/members", cfg.LDAPGroups.AddMembers)
|
||||
ldapGroups.Delete("/:cn/members/:uid", cfg.LDAPGroups.RemoveMember)
|
||||
|
||||
// LDAP Entities (generic CRUD)
|
||||
ldapEntities := api.Group("/ldap/entities")
|
||||
ldapEntities.Get("/", cfg.LDAPEntities.ListTypes)
|
||||
ldapEntities.Get("/:type", cfg.LDAPEntities.List)
|
||||
ldapEntities.Post("/:type", cfg.LDAPEntities.Create)
|
||||
ldapEntities.Get("/:type/:rdn", cfg.LDAPEntities.Get)
|
||||
ldapEntities.Put("/:type/:rdn", cfg.LDAPEntities.Update)
|
||||
ldapEntities.Delete("/:type/:rdn", cfg.LDAPEntities.Delete)
|
||||
|
||||
// DNS Zones
|
||||
dnsZones := api.Group("/dns/zones")
|
||||
dnsZones.Get("/", cfg.DNSZones.List)
|
||||
dnsZones.Get("/:zoneId", cfg.DNSZones.Get)
|
||||
dnsZones.Post("/", cfg.DNSZones.Create)
|
||||
dnsZones.Put("/:zoneId", cfg.DNSZones.Update)
|
||||
dnsZones.Delete("/:zoneId", cfg.DNSZones.Delete)
|
||||
dnsZones.Post("/:zoneId/notify", cfg.DNSZones.Notify)
|
||||
|
||||
// DNS Records
|
||||
dnsZones.Get("/:zoneId/records", cfg.DNSRecords.List)
|
||||
dnsZones.Post("/:zoneId/records", cfg.DNSRecords.Create)
|
||||
dnsZones.Put("/:zoneId/records", cfg.DNSRecords.Replace)
|
||||
dnsZones.Delete("/:zoneId/records", cfg.DNSRecords.Delete)
|
||||
|
||||
// DNS Domains (orchestrated)
|
||||
dnsDomains := api.Group("/dns/domains")
|
||||
dnsDomains.Post("/setup", cfg.DNSRecords.DomainSetup)
|
||||
dnsDomains.Post("/verify", cfg.DNSRecords.DomainVerify)
|
||||
|
||||
// DB Tenants
|
||||
dbTenants := api.Group("/db/tenants")
|
||||
dbTenants.Get("/", cfg.DBTenants.List)
|
||||
dbTenants.Get("/:id", cfg.DBTenants.Get)
|
||||
dbTenants.Post("/", cfg.DBTenants.Create)
|
||||
dbTenants.Put("/:id", cfg.DBTenants.Update)
|
||||
dbTenants.Delete("/:id", cfg.DBTenants.Delete)
|
||||
|
||||
// DB Users
|
||||
dbUsers := api.Group("/db/users")
|
||||
dbUsers.Get("/", cfg.DBUsers.List)
|
||||
dbUsers.Get("/:id", cfg.DBUsers.Get)
|
||||
dbUsers.Post("/", cfg.DBUsers.Create)
|
||||
dbUsers.Put("/:id", cfg.DBUsers.Update)
|
||||
dbUsers.Delete("/:id", cfg.DBUsers.Delete)
|
||||
|
||||
// Certificates
|
||||
certs := api.Group("/certs")
|
||||
certs.Get("/", cfg.Certs.List)
|
||||
certs.Get("/:serialNumber", cfg.Certs.Get)
|
||||
certs.Post("/request", cfg.Certs.Request)
|
||||
certs.Post("/:serialNumber/renew", cfg.Certs.Renew)
|
||||
certs.Post("/:serialNumber/revoke", cfg.Certs.Revoke)
|
||||
|
||||
// PGP Keys
|
||||
pgp := api.Group("/pgp/keys")
|
||||
pgp.Get("/", cfg.PGP.Search)
|
||||
pgp.Get("/:keyId", cfg.PGP.Get)
|
||||
pgp.Post("/", cfg.PGP.Upload)
|
||||
pgp.Delete("/:keyId", cfg.PGP.Delete)
|
||||
|
||||
// PBX
|
||||
if cfg.PBX != nil {
|
||||
pbxTrunks := api.Group("/pbx/trunks")
|
||||
pbxTrunks.Get("/", cfg.PBX.ListTrunks)
|
||||
pbxTrunks.Post("/", cfg.PBX.CreateTrunk)
|
||||
pbxTrunks.Get("/:id", cfg.PBX.GetTrunk)
|
||||
pbxTrunks.Put("/:id", cfg.PBX.UpdateTrunk)
|
||||
pbxTrunks.Delete("/:id", cfg.PBX.DeleteTrunk)
|
||||
pbxTrunks.Post("/:id/activate", cfg.PBX.ActivateTrunk)
|
||||
pbxTrunks.Post("/:id/deactivate", cfg.PBX.DeactivateTrunk)
|
||||
pbxTrunks.Get("/:id/dids", cfg.PBX.ListTrunkDIDs)
|
||||
pbxTrunks.Post("/:id/dids", cfg.PBX.CreateTrunkDID)
|
||||
pbxTrunks.Delete("/:id/dids/:didId", cfg.PBX.DeleteTrunkDID)
|
||||
|
||||
pbxExts := api.Group("/pbx/extensions")
|
||||
pbxExts.Get("/", cfg.PBX.ListExtensions)
|
||||
pbxExts.Post("/", cfg.PBX.CreateExtension)
|
||||
pbxExts.Get("/:id", cfg.PBX.GetExtension)
|
||||
pbxExts.Put("/:id", cfg.PBX.UpdateExtension)
|
||||
pbxExts.Delete("/:id", cfg.PBX.DeleteExtension)
|
||||
|
||||
pbxInbound := api.Group("/pbx/inbound-routes")
|
||||
pbxInbound.Get("/", cfg.PBX.ListInboundRoutes)
|
||||
pbxInbound.Post("/", cfg.PBX.CreateInboundRoute)
|
||||
pbxInbound.Get("/:id", cfg.PBX.GetInboundRoute)
|
||||
pbxInbound.Put("/:id", cfg.PBX.UpdateInboundRoute)
|
||||
pbxInbound.Delete("/:id", cfg.PBX.DeleteInboundRoute)
|
||||
|
||||
pbxOutbound := api.Group("/pbx/outbound-routes")
|
||||
pbxOutbound.Get("/", cfg.PBX.ListOutboundRoutes)
|
||||
pbxOutbound.Post("/", cfg.PBX.CreateOutboundRoute)
|
||||
pbxOutbound.Get("/:id", cfg.PBX.GetOutboundRoute)
|
||||
pbxOutbound.Put("/:id", cfg.PBX.UpdateOutboundRoute)
|
||||
pbxOutbound.Delete("/:id", cfg.PBX.DeleteOutboundRoute)
|
||||
|
||||
api.Get("/pbx/status", cfg.PBX.Status)
|
||||
api.Post("/pbx/reload", cfg.PBX.Reload)
|
||||
}
|
||||
|
||||
// Voice Agents
|
||||
if cfg.VoiceAgent != nil {
|
||||
voiceAgents := api.Group("/voice-agents")
|
||||
voiceAgents.Get("/", cfg.VoiceAgent.ListConfigs)
|
||||
voiceAgents.Post("/", cfg.VoiceAgent.CreateConfig)
|
||||
voiceAgents.Get("/sessions/:sessionId", cfg.VoiceAgent.GetSession)
|
||||
voiceAgents.Get("/:id", cfg.VoiceAgent.GetConfig)
|
||||
voiceAgents.Put("/:id", cfg.VoiceAgent.UpdateConfig)
|
||||
voiceAgents.Delete("/:id", cfg.VoiceAgent.DeleteConfig)
|
||||
voiceAgents.Get("/:id/sessions", cfg.VoiceAgent.ListSessions)
|
||||
}
|
||||
|
||||
// Personal Agents (user agent configs)
|
||||
if cfg.PersonalAgent != nil {
|
||||
agents := api.Group("/agents")
|
||||
agents.Get("/me", cfg.PersonalAgent.GetMyConfig)
|
||||
agents.Put("/me", cfg.PersonalAgent.UpsertMyConfig)
|
||||
agents.Delete("/me", cfg.PersonalAgent.DeleteMyConfig)
|
||||
}
|
||||
|
||||
// Personas
|
||||
if cfg.Persona != nil {
|
||||
personas := api.Group("/personas")
|
||||
personas.Get("/", cfg.Persona.ListPersonas)
|
||||
personas.Post("/", cfg.Persona.CreatePersona)
|
||||
personas.Get("/:id", cfg.Persona.GetPersona)
|
||||
personas.Put("/:id", cfg.Persona.UpdatePersona)
|
||||
personas.Delete("/:id", cfg.Persona.DeletePersona)
|
||||
personas.Get("/:id/self-model", cfg.Persona.GetSelfModel)
|
||||
personas.Get("/:id/experiences", cfg.Persona.GetExperiences)
|
||||
personas.Get("/:id/evaluations/:sessionId", cfg.Persona.GetEvaluations)
|
||||
personas.Get("/:id/moral-pattern/:sessionId", cfg.Persona.GetMoralPattern)
|
||||
}
|
||||
|
||||
// CardDAV
|
||||
if cfg.CardDAV != nil {
|
||||
cardDAVPrincipals := api.Group("/carddav/principals")
|
||||
cardDAVPrincipals.Get("/", cfg.CardDAV.ListPrincipals)
|
||||
cardDAVPrincipals.Get("/:username", cfg.CardDAV.GetPrincipal)
|
||||
cardDAVPrincipals.Post("/", cfg.CardDAV.CreatePrincipal)
|
||||
cardDAVPrincipals.Delete("/:username", cfg.CardDAV.DeletePrincipal)
|
||||
|
||||
cardDAVBooks := api.Group("/carddav/addressbooks")
|
||||
cardDAVBooks.Get("/", cfg.CardDAV.ListAddressBooks)
|
||||
cardDAVBooks.Get("/:id", cfg.CardDAV.GetAddressBook)
|
||||
cardDAVBooks.Post("/", cfg.CardDAV.CreateAddressBook)
|
||||
cardDAVBooks.Put("/:id", cfg.CardDAV.UpdateAddressBook)
|
||||
cardDAVBooks.Delete("/:id", cfg.CardDAV.DeleteAddressBook)
|
||||
|
||||
cardDAVBooks.Get("/:id/contacts", cfg.CardDAV.ListContacts)
|
||||
cardDAVBooks.Get("/:id/contacts/:uri", cfg.CardDAV.GetContact)
|
||||
cardDAVBooks.Post("/:id/contacts", cfg.CardDAV.CreateContact)
|
||||
cardDAVBooks.Put("/:id/contacts/:uri", cfg.CardDAV.UpdateContact)
|
||||
cardDAVBooks.Delete("/:id/contacts/:uri", cfg.CardDAV.DeleteContact)
|
||||
}
|
||||
}
|
||||
341
internal/schema/attributes.go
Normal file
341
internal/schema/attributes.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package schema
|
||||
|
||||
// registerAttributes registers all 292 GoSec LDAP attribute definitions
|
||||
// organized by OID branch/domain.
|
||||
func (r *Registry) registerAttributes() {
|
||||
// ── common (7) ──────────────────────────────────────────────────
|
||||
r.addAttr("gscCreatedAt", "createdAt", AttrTime, "common", true)
|
||||
r.addAttr("gscModifiedAt", "modifiedAt", AttrTime, "common", true)
|
||||
r.addAttr("gscCreatedBy", "createdBy", AttrDN, "common", true)
|
||||
r.addAttr("gscModifiedBy", "modifiedBy", AttrDN, "common", true)
|
||||
r.addAttr("gscDescription", "description", AttrString, "common", false)
|
||||
r.addAttr("gscEnabled", "enabled", AttrBool, "common", false)
|
||||
r.addAttr("gscNotes", "notes", AttrString, "common", false)
|
||||
|
||||
// ── tenant (10) ─────────────────────────────────────────────────
|
||||
r.addAttr("gscTenantId", "tenantId", AttrString, "tenant", false)
|
||||
r.addAttr("gscTenantName", "tenantName", AttrString, "tenant", false)
|
||||
r.addAttr("gscTenantDomain", "tenantDomain", AttrString, "tenant", false)
|
||||
r.addAttr("gscTenantStatus", "tenantStatus", AttrString, "tenant", false)
|
||||
r.addAttr("gscTenantQuota", "tenantQuota", AttrInt, "tenant", false)
|
||||
r.addAttr("gscTenantMaxUsers", "tenantMaxUsers", AttrInt, "tenant", false)
|
||||
r.addAttr("gscTenantCreatedAt", "tenantCreatedAt", AttrTime, "tenant", true)
|
||||
r.addAttr("gscTenantServices", "tenantServices", AttrStringMulti, "tenant", false)
|
||||
r.addAttr("gscTenantAdminDN", "tenantAdminDN", AttrDN, "tenant", false)
|
||||
r.addAttr("gscTenantParentDN", "tenantParentDN", AttrDN, "tenant", false)
|
||||
|
||||
// ── hash (5) ────────────────────────────────────────────────────
|
||||
r.addAttr("gscUserTenantHash", "tenantHash", AttrString, "hash", true)
|
||||
r.addAttr("gscUserTenantHashSalt", "tenantHashSalt", AttrString, "hash", true)
|
||||
r.addAttr("gscUserTenantHashVersion", "tenantHashVersion", AttrInt, "hash", true)
|
||||
r.addAttr("gscUserTenantHashCreatedAt", "tenantHashCreatedAt", AttrTime, "hash", true)
|
||||
r.addAttr("gscUserTenantHashVerifiedAt", "tenantHashVerifiedAt", AttrTime, "hash", true)
|
||||
|
||||
// ── customer (7) ────────────────────────────────────────────────
|
||||
r.addAttr("gscCustomerId", "customerId", AttrString, "customer", false)
|
||||
r.addAttr("gscSID", "sid", AttrString, "customer", false)
|
||||
r.addAttr("gscSIDCustomerPart", "sidCustomerPart", AttrString, "customer", false)
|
||||
r.addAttr("gscSIDTenantPart", "sidTenantPart", AttrString, "customer", false)
|
||||
r.addAttr("gscSIDSpecial1", "sidSpecial1", AttrString, "customer", false)
|
||||
r.addAttr("gscSIDSpecial2", "sidSpecial2", AttrString, "customer", false)
|
||||
r.addAttr("gscSIDUserPart", "sidUserPart", AttrString, "customer", false)
|
||||
|
||||
// ── mail (8) ────────────────────────────────────────────────────
|
||||
r.addAttr("gscMailEnabled", "enabled", AttrBool, "mail", false)
|
||||
r.addAttr("gscMailQuota", "quota", AttrInt, "mail", false)
|
||||
r.addAttr("gscMailAlias", "alias", AttrStringMulti, "mail", false)
|
||||
r.addAttr("gscMailForward", "forward", AttrString, "mail", false)
|
||||
r.addAttr("gscMailAutoReply", "autoReply", AttrBool, "mail", false)
|
||||
r.addAttr("gscMailAutoReplyMessage", "autoReplyMessage", AttrString, "mail", false)
|
||||
r.addAttr("gscMailTransport", "transport", AttrString, "mail", false)
|
||||
r.addAttr("gscMailDomain", "domain", AttrString, "mail", false)
|
||||
|
||||
// ── conf (5) ────────────────────────────────────────────────────
|
||||
r.addAttr("gscConfEnabled", "enabled", AttrBool, "conf", false)
|
||||
r.addAttr("gscConfRole", "role", AttrString, "conf", false)
|
||||
r.addAttr("gscConfMaxParticipants", "maxParticipants", AttrInt, "conf", false)
|
||||
r.addAttr("gscConfRecordingEnabled", "recordingEnabled", AttrBool, "conf", false)
|
||||
r.addAttr("gscConfDefaultRoom", "defaultRoom", AttrString, "conf", false)
|
||||
|
||||
// ── ftp (6) ─────────────────────────────────────────────────────
|
||||
r.addAttr("gscFtpEnabled", "enabled", AttrBool, "ftp", false)
|
||||
r.addAttr("gscFtpQuota", "quota", AttrInt, "ftp", false)
|
||||
r.addAttr("gscFtpHomeDir", "homeDir", AttrString, "ftp", false)
|
||||
r.addAttr("gscFtpUploadBandwidth", "uploadBandwidth", AttrInt, "ftp", false)
|
||||
r.addAttr("gscFtpDownloadBandwidth", "downloadBandwidth", AttrInt, "ftp", false)
|
||||
r.addAttr("gscFtpAllowedIPs", "allowedIPs", AttrStringMulti, "ftp", false)
|
||||
|
||||
// ── file (5) ────────────────────────────────────────────────────
|
||||
r.addAttr("gscFileEnabled", "enabled", AttrBool, "file", false)
|
||||
r.addAttr("gscFileQuota", "quota", AttrInt, "file", false)
|
||||
r.addAttr("gscFileHomeDir", "homeDir", AttrString, "file", false)
|
||||
r.addAttr("gscFileVersioning", "versioning", AttrBool, "file", false)
|
||||
r.addAttr("gscFileMaxFileSize", "maxFileSize", AttrInt, "file", false)
|
||||
|
||||
// ── sharing (5) ────────────────────────────────────────────────
|
||||
r.addAttr("gscShareEnabled", "enabled", AttrBool, "sharing", false)
|
||||
r.addAttr("gscShareExternalEnabled", "externalEnabled", AttrBool, "sharing", false)
|
||||
r.addAttr("gscShareMaxRecipients", "maxRecipients", AttrInt, "sharing", false)
|
||||
r.addAttr("gscShareDefaultExpiry", "defaultExpiry", AttrInt, "sharing", false)
|
||||
r.addAttr("gscSharePasswordRequired", "passwordRequired", AttrBool, "sharing", false)
|
||||
|
||||
// ── calendar (5) ───────────────────────────────────────────────
|
||||
r.addAttr("gscCalEnabled", "enabled", AttrBool, "calendar", false)
|
||||
r.addAttr("gscCalDefaultCalendar", "defaultCalendar", AttrString, "calendar", false)
|
||||
r.addAttr("gscCalTimezone", "timezone", AttrString, "calendar", false)
|
||||
r.addAttr("gscCalFreeBusyPublic", "freeBusyPublic", AttrBool, "calendar", false)
|
||||
r.addAttr("gscCalDelegates", "delegates", AttrDNMulti, "calendar", false)
|
||||
|
||||
// ── telephony (8) ──────────────────────────────────────────────
|
||||
r.addAttr("gscTelEnabled", "enabled", AttrBool, "telephony", false)
|
||||
r.addAttr("gscTelExtension", "extension", AttrString, "telephony", false)
|
||||
r.addAttr("gscTelDID", "did", AttrString, "telephony", false)
|
||||
r.addAttr("gscTelVoicemailEnabled", "voicemailEnabled", AttrBool, "telephony", false)
|
||||
r.addAttr("gscTelVoicemailPin", "voicemailPin", AttrString, "telephony", false)
|
||||
r.addAttr("gscTelCallForward", "callForward", AttrString, "telephony", false)
|
||||
r.addAttr("gscTelCallGroup", "callGroup", AttrString, "telephony", false)
|
||||
r.addAttr("gscTelRecordCalls", "recordCalls", AttrBool, "telephony", false)
|
||||
|
||||
// ── contacts (4) ──────────────────────────────────────────────
|
||||
r.addAttr("gscContactsEnabled", "enabled", AttrBool, "contacts", false)
|
||||
r.addAttr("gscContactsShared", "shared", AttrBool, "contacts", false)
|
||||
r.addAttr("gscContactsMaxContacts", "maxContacts", AttrInt, "contacts", false)
|
||||
r.addAttr("gscContactsExportEnabled", "exportEnabled", AttrBool, "contacts", false)
|
||||
|
||||
// ── ai (5) ─────────────────────────────────────────────────────
|
||||
r.addAttr("gscAIEnabled", "enabled", AttrBool, "ai", false)
|
||||
r.addAttr("gscAIModel", "model", AttrString, "ai", false)
|
||||
r.addAttr("gscAIMaxTokens", "maxTokens", AttrInt, "ai", false)
|
||||
r.addAttr("gscAIFeatures", "features", AttrStringMulti, "ai", false)
|
||||
r.addAttr("gscAIUsageQuota", "usageQuota", AttrInt, "ai", false)
|
||||
|
||||
// ── resource (9) ──────────────────────────────────────────────
|
||||
r.addAttr("gscResourceId", "resourceId", AttrString, "resource", false)
|
||||
r.addAttr("gscResourceName", "resourceName", AttrString, "resource", false)
|
||||
r.addAttr("gscResourceType", "resourceType", AttrString, "resource", false)
|
||||
r.addAttr("gscResourceEmail", "resourceEmail", AttrString, "resource", false)
|
||||
r.addAttr("gscResourceCapacity", "capacity", AttrInt, "resource", false)
|
||||
r.addAttr("gscResourceLocation", "location", AttrString, "resource", false)
|
||||
r.addAttr("gscResourceBookable", "bookable", AttrBool, "resource", false)
|
||||
r.addAttr("gscResourceApprovalRequired", "approvalRequired", AttrBool, "resource", false)
|
||||
r.addAttr("gscResourceOwnerDN", "ownerDN", AttrDN, "resource", false)
|
||||
|
||||
// ── dlp (10) ──────────────────────────────────────────────────
|
||||
r.addAttr("gscDlpEnabled", "enabled", AttrBool, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyDN", "policyDN", AttrDNMulti, "dlp", false)
|
||||
r.addAttr("gscDlpExempt", "exempt", AttrBool, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyName", "policyName", AttrString, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyType", "policyType", AttrString, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyRules", "policyRules", AttrStringMulti, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyAction", "policyAction", AttrString, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyPriority", "policyPriority", AttrInt, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyScope", "policyScope", AttrStringMulti, "dlp", false)
|
||||
r.addAttr("gscDlpPolicyStatus", "policyStatus", AttrString, "dlp", false)
|
||||
|
||||
// ── sensitivity (9) ──────────────────────────────────────────
|
||||
r.addAttr("gscSensitivityEnabled", "enabled", AttrBool, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityDefaultLabel", "defaultLabel", AttrString, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityLabelName", "labelName", AttrString, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityLabelPriority", "labelPriority", AttrInt, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityLabelColor", "labelColor", AttrString, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityLabelTooltip", "labelTooltip", AttrString, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityLabelScope", "labelScope", AttrStringMulti, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityEncryptionRequired", "encryptionRequired", AttrBool, "sensitivity", false)
|
||||
r.addAttr("gscSensitivityWatermark", "watermark", AttrBool, "sensitivity", false)
|
||||
|
||||
// ── encryption (8) ──────────────────────────────────────────
|
||||
r.addAttr("gscEncryptionEnabled", "enabled", AttrBool, "encryption", false)
|
||||
r.addAttr("gscEncryptionKeyDN", "keyDN", AttrDN, "encryption", false)
|
||||
r.addAttr("gscEncryptionPolicyName", "policyName", AttrString, "encryption", false)
|
||||
r.addAttr("gscEncryptionPolicyType", "policyType", AttrString, "encryption", false)
|
||||
r.addAttr("gscEncryptionAlgorithm", "algorithm", AttrString, "encryption", false)
|
||||
r.addAttr("gscEncryptionKeyLength", "keyLength", AttrInt, "encryption", false)
|
||||
r.addAttr("gscEncryptionScope", "scope", AttrStringMulti, "encryption", false)
|
||||
r.addAttr("gscEncryptionPolicyStatus", "policyStatus", AttrString, "encryption", false)
|
||||
|
||||
// ── retention (10) ──────────────────────────────────────────
|
||||
r.addAttr("gscRetentionEnabled", "enabled", AttrBool, "retention", false)
|
||||
r.addAttr("gscRetentionPolicyDN", "policyDN", AttrDNMulti, "retention", false)
|
||||
r.addAttr("gscRetentionPolicyName", "policyName", AttrString, "retention", false)
|
||||
r.addAttr("gscRetentionPolicyType", "policyType", AttrString, "retention", false)
|
||||
r.addAttr("gscRetentionDuration", "duration", AttrInt, "retention", false)
|
||||
r.addAttr("gscRetentionAction", "action", AttrString, "retention", false)
|
||||
r.addAttr("gscRetentionScope", "scope", AttrStringMulti, "retention", false)
|
||||
r.addAttr("gscRetentionPolicyStatus", "policyStatus", AttrString, "retention", false)
|
||||
r.addAttr("gscRetentionExcludeFolders", "excludeFolders", AttrStringMulti, "retention", false)
|
||||
r.addAttr("gscRetentionLegalHold", "legalHold", AttrBool, "retention", false)
|
||||
|
||||
// ── ediscovery (11) ─────────────────────────────────────────
|
||||
r.addAttr("gscEDiscoveryEnabled", "enabled", AttrBool, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryCustodian", "custodian", AttrBool, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryCaseName", "caseName", AttrString, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryCaseStatus", "caseStatus", AttrString, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryCaseCreatedAt", "caseCreatedAt", AttrTime, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryCaseClosedAt", "caseClosedAt", AttrTime, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryHoldName", "holdName", AttrString, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryHoldScope", "holdScope", AttrStringMulti, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryHoldStatus", "holdStatus", AttrString, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoveryHoldCreatedAt", "holdCreatedAt", AttrTime, "ediscovery", false)
|
||||
r.addAttr("gscEDiscoverySearchQuery", "searchQuery", AttrString, "ediscovery", false)
|
||||
|
||||
// ── audit (9) ───────────────────────────────────────────────
|
||||
r.addAttr("gscAuditEnabled", "enabled", AttrBool, "audit", false)
|
||||
r.addAttr("gscAuditLevel", "level", AttrString, "audit", false)
|
||||
r.addAttr("gscAuditPolicyName", "policyName", AttrString, "audit", false)
|
||||
r.addAttr("gscAuditPolicyScope", "policyScope", AttrStringMulti, "audit", false)
|
||||
r.addAttr("gscAuditPolicyActions", "policyActions", AttrStringMulti, "audit", false)
|
||||
r.addAttr("gscAuditPolicyStatus", "policyStatus", AttrString, "audit", false)
|
||||
r.addAttr("gscAuditRetentionDays", "retentionDays", AttrInt, "audit", false)
|
||||
r.addAttr("gscAuditAlertEnabled", "alertEnabled", AttrBool, "audit", false)
|
||||
r.addAttr("gscAuditAlertRecipients", "alertRecipients", AttrStringMulti, "audit", false)
|
||||
|
||||
// ── iam (12) ────────────────────────────────────────────────
|
||||
r.addAttr("gscIAMEnabled", "enabled", AttrBool, "iam", false)
|
||||
r.addAttr("gscIAMMFARequired", "mfaRequired", AttrBool, "iam", false)
|
||||
r.addAttr("gscIAMMFAMethod", "mfaMethod", AttrString, "iam", false)
|
||||
r.addAttr("gscIAMPasswordPolicy", "passwordPolicy", AttrString, "iam", false)
|
||||
r.addAttr("gscIAMSessionTimeout", "sessionTimeout", AttrInt, "iam", false)
|
||||
r.addAttr("gscIAMMaxSessions", "maxSessions", AttrInt, "iam", false)
|
||||
r.addAttr("gscIAMIPRestrictions", "ipRestrictions", AttrStringMulti, "iam", false)
|
||||
r.addAttr("gscIAMRiskLevel", "riskLevel", AttrString, "iam", false)
|
||||
r.addAttr("gscIAMCAPolicyName", "caPolicyName", AttrString, "iam", false)
|
||||
r.addAttr("gscIAMCAPolicyConditions", "caPolicyConditions", AttrStringMulti, "iam", false)
|
||||
r.addAttr("gscIAMCAPolicyActions", "caPolicyActions", AttrStringMulti, "iam", false)
|
||||
r.addAttr("gscIAMCAPolicyStatus", "caPolicyStatus", AttrString, "iam", false)
|
||||
|
||||
// ── collaboration (10) ──────────────────────────────────────
|
||||
r.addAttr("gscCollabEnabled", "enabled", AttrBool, "collaboration", false)
|
||||
r.addAttr("gscCollabTeamsEnabled", "teamsEnabled", AttrBool, "collaboration", false)
|
||||
r.addAttr("gscCollabChannelsEnabled", "channelsEnabled", AttrBool, "collaboration", false)
|
||||
r.addAttr("gscCollabExternalEnabled", "externalEnabled", AttrBool, "collaboration", false)
|
||||
r.addAttr("gscCollabPolicyName", "policyName", AttrString, "collaboration", false)
|
||||
r.addAttr("gscCollabPolicyScope", "policyScope", AttrStringMulti, "collaboration", false)
|
||||
r.addAttr("gscCollabPolicyActions", "policyActions", AttrStringMulti, "collaboration", false)
|
||||
r.addAttr("gscCollabPolicyStatus", "policyStatus", AttrString, "collaboration", false)
|
||||
r.addAttr("gscCollabMaxTeamSize", "maxTeamSize", AttrInt, "collaboration", false)
|
||||
r.addAttr("gscCollabGuestAccessEnabled", "guestAccessEnabled", AttrBool, "collaboration", false)
|
||||
|
||||
// ── barriers (9) ───────────────────────────────────────────
|
||||
r.addAttr("gscBarrierEnabled", "enabled", AttrBool, "barriers", false)
|
||||
r.addAttr("gscBarrierSegmentDN", "segmentDN", AttrDNMulti, "barriers", false)
|
||||
r.addAttr("gscBarrierSegmentName", "segmentName", AttrString, "barriers", false)
|
||||
r.addAttr("gscBarrierSegmentMembers", "segmentMembers", AttrDNMulti, "barriers", false)
|
||||
r.addAttr("gscBarrierPolicyName", "policyName", AttrString, "barriers", false)
|
||||
r.addAttr("gscBarrierPolicyType", "policyType", AttrString, "barriers", false)
|
||||
r.addAttr("gscBarrierPolicySegments", "policySegments", AttrStringMulti, "barriers", false)
|
||||
r.addAttr("gscBarrierPolicyAction", "policyAction", AttrString, "barriers", false)
|
||||
r.addAttr("gscBarrierPolicyStatus", "policyStatus", AttrString, "barriers", false)
|
||||
|
||||
// ── guest (26) ─────────────────────────────────────────────
|
||||
// identity core
|
||||
r.addAttr("gscGuestEnabled", "enabled", AttrBool, "guest", false)
|
||||
r.addAttr("gscGuestType", "type", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestOrganization", "organization", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestExternalEmail", "externalEmail", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestVerified", "verified", AttrBool, "guest", false)
|
||||
// federation
|
||||
r.addAttr("gscGuestFederatedIdpDN", "federatedIdpDN", AttrDN, "guest", false)
|
||||
r.addAttr("gscGuestFederatedId", "federatedId", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestIdpName", "idpName", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestIdpType", "idpType", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestIdpEntityId", "idpEntityId", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestIdpMetadataURL", "idpMetadataURL", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestIdpDomains", "idpDomains", AttrStringMulti, "guest", false)
|
||||
r.addAttr("gscGuestIdpStatus", "idpStatus", AttrString, "guest", false)
|
||||
// access control
|
||||
r.addAttr("gscGuestAccessScope", "accessScope", AttrStringMulti, "guest", false)
|
||||
r.addAttr("gscGuestPermissionLevel", "permissionLevel", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestResourceDN", "resourceDN", AttrDNMulti, "guest", false)
|
||||
// invitation/audit
|
||||
r.addAttr("gscGuestInvitedBy", "invitedBy", AttrDN, "guest", true)
|
||||
r.addAttr("gscGuestInvitedAt", "invitedAt", AttrTime, "guest", true)
|
||||
r.addAttr("gscGuestExpiresAt", "expiresAt", AttrTime, "guest", false)
|
||||
r.addAttr("gscGuestLastAccessAt", "lastAccessAt", AttrTime, "guest", true)
|
||||
r.addAttr("gscGuestAccessCount", "accessCount", AttrInt, "guest", true)
|
||||
// policy/restrictions
|
||||
r.addAttr("gscGuestPolicyName", "policyName", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestPolicyScope", "policyScope", AttrStringMulti, "guest", false)
|
||||
r.addAttr("gscGuestPolicyAction", "policyAction", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestPolicyStatus", "policyStatus", AttrString, "guest", false)
|
||||
r.addAttr("gscGuestMaxDuration", "maxDuration", AttrInt, "guest", false)
|
||||
|
||||
// ── kms-user (12) ──────────────────────────────────────────
|
||||
r.addAttr("gscKmsEnabled", "enabled", AttrBool, "kms-user", false)
|
||||
r.addAttr("gscKmsRole", "role", AttrString, "kms-user", false)
|
||||
r.addAttr("gscKmsKeyAccess", "keyAccess", AttrStringMulti, "kms-user", false)
|
||||
r.addAttr("gscKmsMaxKeys", "maxKeys", AttrInt, "kms-user", false)
|
||||
r.addAttr("gscKmsAllowedAlgorithms", "allowedAlgorithms", AttrStringMulti, "kms-user", false)
|
||||
r.addAttr("gscKmsAllowedOperations", "allowedOperations", AttrStringMulti, "kms-user", false)
|
||||
r.addAttr("gscKmsApprovalRequired", "approvalRequired", AttrBool, "kms-user", false)
|
||||
r.addAttr("gscKmsAuditEnabled", "auditEnabled", AttrBool, "kms-user", false)
|
||||
r.addAttr("gscKmsLastKeyAccess", "lastKeyAccess", AttrTime, "kms-user", true)
|
||||
r.addAttr("gscKmsKeyCount", "keyCount", AttrInt, "kms-user", true)
|
||||
r.addAttr("gscKmsPolicyDN", "policyDN", AttrDNMulti, "kms-user", false)
|
||||
r.addAttr("gscKmsHsmAccess", "hsmAccess", AttrBool, "kms-user", false)
|
||||
|
||||
// ── kms-tenant (12) ────────────────────────────────────────
|
||||
r.addAttr("gscKmsTenantEnabled", "enabled", AttrBool, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantMaxKeys", "maxKeys", AttrInt, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantAllowedAlgorithms", "allowedAlgorithms", AttrStringMulti, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantKeyRotationDays", "keyRotationDays", AttrInt, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantHsmEnabled", "hsmEnabled", AttrBool, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantHsmPartition", "hsmPartition", AttrString, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantAutoRotate", "autoRotate", AttrBool, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantKeyCount", "keyCount", AttrInt, "kms-tenant", true)
|
||||
r.addAttr("gscKmsTenantQuota", "quota", AttrInt, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantDefaultAlgorithm", "defaultAlgorithm", AttrString, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantDefaultKeyLength", "defaultKeyLength", AttrInt, "kms-tenant", false)
|
||||
r.addAttr("gscKmsTenantPolicyDN", "policyDN", AttrDNMulti, "kms-tenant", false)
|
||||
|
||||
// ── managed-key (21) ───────────────────────────────────────
|
||||
r.addAttr("gscKeyId", "keyId", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyName", "keyName", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyAlgorithm", "algorithm", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyLength", "keyLength", AttrInt, "managed-key", false)
|
||||
r.addAttr("gscKeyType", "keyType", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyStatus", "status", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyCreatedAt", "createdAt", AttrTime, "managed-key", true)
|
||||
r.addAttr("gscKeyExpiresAt", "expiresAt", AttrTime, "managed-key", false)
|
||||
r.addAttr("gscKeyRotatedAt", "rotatedAt", AttrTime, "managed-key", true)
|
||||
r.addAttr("gscKeyOwnerDN", "ownerDN", AttrDN, "managed-key", false)
|
||||
r.addAttr("gscKeyTenantDN", "tenantDN", AttrDN, "managed-key", false)
|
||||
r.addAttr("gscKeyOperations", "operations", AttrStringMulti, "managed-key", false)
|
||||
r.addAttr("gscKeyMaterial", "material", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyPublicKey", "publicKey", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyFingerprint", "fingerprint", AttrString, "managed-key", true)
|
||||
r.addAttr("gscKeyVersion", "version", AttrInt, "managed-key", false)
|
||||
r.addAttr("gscKeyPreviousVersionDN", "previousVersionDN", AttrDN, "managed-key", false)
|
||||
r.addAttr("gscKeyHsmBacked", "hsmBacked", AttrBool, "managed-key", false)
|
||||
r.addAttr("gscKeyHsmSlot", "hsmSlot", AttrString, "managed-key", false)
|
||||
r.addAttr("gscKeyAutoRotate", "autoRotate", AttrBool, "managed-key", false)
|
||||
r.addAttr("gscKeyRotationDays", "rotationDays", AttrInt, "managed-key", false)
|
||||
|
||||
// ── kms-policy (15) ────────────────────────────────────────
|
||||
r.addAttr("gscKmsPolicyId", "policyId", AttrString, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyName", "policyName", AttrString, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyType", "policyType", AttrString, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyEffect", "effect", AttrString, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyPrincipalDN", "principalDN", AttrDNMulti, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyResourceDN", "resourceDN", AttrDNMulti, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyOperations", "operations", AttrStringMulti, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyConditions", "conditions", AttrStringMulti, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyPriority", "priority", AttrInt, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyStatus", "status", AttrString, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyCreatedAt", "createdAt", AttrTime, "kms-policy", true)
|
||||
r.addAttr("gscKmsPolicyModifiedAt", "modifiedAt", AttrTime, "kms-policy", true)
|
||||
r.addAttr("gscKmsPolicyTenantDN", "tenantDN", AttrDN, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyMaxKeyAge", "maxKeyAge", AttrInt, "kms-policy", false)
|
||||
r.addAttr("gscKmsPolicyRequireHsm", "requireHsm", AttrBool, "kms-policy", false)
|
||||
|
||||
// ── hsm-config (10) ────────────────────────────────────────
|
||||
r.addAttr("gscHsmConfigId", "configId", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigName", "configName", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigType", "type", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigVendor", "vendor", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigModel", "model", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigConnectionString", "connectionString", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigSlots", "slots", AttrStringMulti, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigStatus", "status", AttrString, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigMaxKeys", "maxKeys", AttrInt, "hsm-config", false)
|
||||
r.addAttr("gscHsmConfigTenantDN", "tenantDN", AttrDNMulti, "hsm-config", false)
|
||||
}
|
||||
112
internal/schema/entities.go
Normal file
112
internal/schema/entities.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package schema
|
||||
|
||||
// registerEntities registers all 18 entity type definitions for generic CRUD.
|
||||
func (r *Registry) registerEntities() {
|
||||
r.addEntityType("tenant", "Organization tenant",
|
||||
[]string{"top", "gscTenant"},
|
||||
"ou=tenants", "gscTenantId",
|
||||
"(objectClass=gscTenant)", "tenant",
|
||||
[]string{"gscTenantId", "gscTenantName"})
|
||||
|
||||
r.addEntityType("resource", "Shared resource (room, equipment)",
|
||||
[]string{"top", "gscResource"},
|
||||
"ou=tenants", "gscResourceId",
|
||||
"(objectClass=gscResource)", "resource",
|
||||
[]string{"gscResourceId", "gscResourceName", "gscResourceType"})
|
||||
|
||||
r.addEntityType("dlp-policy", "Data loss prevention policy",
|
||||
[]string{"top", "gscDlpPolicy"},
|
||||
"ou=dlp,ou=compliance", "cn",
|
||||
"(objectClass=gscDlpPolicy)", "dlp",
|
||||
[]string{"gscDlpPolicyName"})
|
||||
|
||||
r.addEntityType("sensitivity-label", "Sensitivity classification label",
|
||||
[]string{"top", "gscSensitivityLabel"},
|
||||
"ou=sensitivity,ou=compliance", "cn",
|
||||
"(objectClass=gscSensitivityLabel)", "sensitivity",
|
||||
[]string{"gscSensitivityLabelName"})
|
||||
|
||||
r.addEntityType("encryption-policy", "Encryption policy",
|
||||
[]string{"top", "gscEncryptionPolicy"},
|
||||
"ou=encryption,ou=compliance", "cn",
|
||||
"(objectClass=gscEncryptionPolicy)", "encryption",
|
||||
[]string{"gscEncryptionPolicyName"})
|
||||
|
||||
r.addEntityType("retention-policy", "Data retention policy",
|
||||
[]string{"top", "gscRetentionPolicy"},
|
||||
"ou=retention,ou=compliance", "cn",
|
||||
"(objectClass=gscRetentionPolicy)", "retention",
|
||||
[]string{"gscRetentionPolicyName"})
|
||||
|
||||
r.addEntityType("ediscovery-case", "eDiscovery case",
|
||||
[]string{"top", "gscEDiscoveryCase"},
|
||||
"ou=ediscovery,ou=compliance", "cn",
|
||||
"(objectClass=gscEDiscoveryCase)", "ediscovery",
|
||||
[]string{"gscEDiscoveryCaseName"})
|
||||
|
||||
r.addEntityType("ediscovery-hold", "eDiscovery legal hold",
|
||||
[]string{"top", "gscEDiscoveryHold"},
|
||||
"ou=ediscovery,ou=compliance", "cn",
|
||||
"(objectClass=gscEDiscoveryHold)", "ediscovery",
|
||||
[]string{"gscEDiscoveryHoldName"})
|
||||
|
||||
r.addEntityType("audit-policy", "Audit logging policy",
|
||||
[]string{"top", "gscAuditPolicy"},
|
||||
"ou=audit,ou=compliance", "cn",
|
||||
"(objectClass=gscAuditPolicy)", "audit",
|
||||
[]string{"gscAuditPolicyName"})
|
||||
|
||||
r.addEntityType("ca-policy", "Conditional access policy",
|
||||
[]string{"top", "gscConditionalAccessPolicy"},
|
||||
"ou=iam,ou=compliance", "cn",
|
||||
"(objectClass=gscConditionalAccessPolicy)", "iam",
|
||||
[]string{"gscIAMCAPolicyName"})
|
||||
|
||||
r.addEntityType("collab-policy", "Collaboration policy",
|
||||
[]string{"top", "gscCollaborationPolicy"},
|
||||
"ou=collaboration,ou=compliance", "cn",
|
||||
"(objectClass=gscCollaborationPolicy)", "collaboration",
|
||||
[]string{"gscCollabPolicyName"})
|
||||
|
||||
r.addEntityType("barrier-segment", "Information barrier segment",
|
||||
[]string{"top", "gscBarrierSegment"},
|
||||
"ou=barriers,ou=compliance", "cn",
|
||||
"(objectClass=gscBarrierSegment)", "barriers",
|
||||
[]string{"gscBarrierSegmentName"})
|
||||
|
||||
r.addEntityType("barrier-policy", "Information barrier policy",
|
||||
[]string{"top", "gscBarrierPolicy"},
|
||||
"ou=barriers,ou=compliance", "cn",
|
||||
"(objectClass=gscBarrierPolicy)", "barriers",
|
||||
[]string{"gscBarrierPolicyName"})
|
||||
|
||||
r.addEntityType("guest-policy", "Guest access policy",
|
||||
[]string{"top", "gscGuestPolicy"},
|
||||
"ou=policies,ou=guests", "cn",
|
||||
"(objectClass=gscGuestPolicy)", "guest",
|
||||
[]string{"gscGuestPolicyName"})
|
||||
|
||||
r.addEntityType("federated-idp", "Federated identity provider",
|
||||
[]string{"top", "gscFederatedIdp"},
|
||||
"ou=idps,ou=guests", "cn",
|
||||
"(objectClass=gscFederatedIdp)", "guest",
|
||||
[]string{"gscGuestIdpName"})
|
||||
|
||||
r.addEntityType("managed-key", "Managed encryption key",
|
||||
[]string{"top", "gscManagedKey"},
|
||||
"ou=keys,ou=keymanagement", "gscKeyId",
|
||||
"(objectClass=gscManagedKey)", "managed-key",
|
||||
[]string{"gscKeyId", "gscKeyName", "gscKeyAlgorithm"})
|
||||
|
||||
r.addEntityType("kms-policy", "Key management policy",
|
||||
[]string{"top", "gscKmsPolicy"},
|
||||
"ou=policies,ou=keymanagement", "gscKmsPolicyId",
|
||||
"(objectClass=gscKmsPolicy)", "kms-policy",
|
||||
[]string{"gscKmsPolicyId", "gscKmsPolicyName"})
|
||||
|
||||
r.addEntityType("hsm-config", "HSM hardware configuration",
|
||||
[]string{"top", "gscHsmConfig"},
|
||||
"ou=hsmconfigs,ou=keymanagement", "gscHsmConfigId",
|
||||
"(objectClass=gscHsmConfig)", "hsm-config",
|
||||
[]string{"gscHsmConfigId", "gscHsmConfigName"})
|
||||
}
|
||||
230
internal/schema/objectclasses.go
Normal file
230
internal/schema/objectclasses.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package schema
|
||||
|
||||
// registerObjectClasses registers all 45 GoSec LDAP objectClass definitions.
|
||||
func (r *Registry) registerObjectClasses() {
|
||||
// ── AUXILIARY user objectClasses (21) ────────────────────────
|
||||
|
||||
r.addObjectClass("gscTenantUser", "AUXILIARY",
|
||||
[]string{"gscTenantId"},
|
||||
[]string{"gscTenantName", "gscTenantDomain", "gscTenantStatus", "gscUserTenantHash", "gscUserTenantHashSalt", "gscUserTenantHashVersion", "gscUserTenantHashCreatedAt", "gscUserTenantHashVerifiedAt", "gscCustomerId", "gscSID", "gscSIDCustomerPart", "gscSIDTenantPart", "gscSIDSpecial1", "gscSIDSpecial2", "gscSIDUserPart", "gscCreatedAt", "gscModifiedAt", "gscCreatedBy", "gscModifiedBy"},
|
||||
"tenant")
|
||||
|
||||
r.addObjectClass("gscMailUser", "AUXILIARY",
|
||||
[]string{"gscMailEnabled"},
|
||||
[]string{"gscMailQuota", "gscMailAlias", "gscMailForward", "gscMailAutoReply", "gscMailAutoReplyMessage", "gscMailTransport", "gscMailDomain"},
|
||||
"mail")
|
||||
|
||||
r.addObjectClass("gscConfUser", "AUXILIARY",
|
||||
[]string{"gscConfEnabled"},
|
||||
[]string{"gscConfRole", "gscConfMaxParticipants", "gscConfRecordingEnabled", "gscConfDefaultRoom"},
|
||||
"conf")
|
||||
|
||||
r.addObjectClass("gscFtpUser", "AUXILIARY",
|
||||
[]string{"gscFtpEnabled"},
|
||||
[]string{"gscFtpQuota", "gscFtpHomeDir", "gscFtpUploadBandwidth", "gscFtpDownloadBandwidth", "gscFtpAllowedIPs"},
|
||||
"ftp")
|
||||
|
||||
r.addObjectClass("gscFileUser", "AUXILIARY",
|
||||
[]string{"gscFileEnabled"},
|
||||
[]string{"gscFileQuota", "gscFileHomeDir", "gscFileVersioning", "gscFileMaxFileSize"},
|
||||
"file")
|
||||
|
||||
r.addObjectClass("gscShareUser", "AUXILIARY",
|
||||
[]string{"gscShareEnabled"},
|
||||
[]string{"gscShareExternalEnabled", "gscShareMaxRecipients", "gscShareDefaultExpiry", "gscSharePasswordRequired"},
|
||||
"sharing")
|
||||
|
||||
r.addObjectClass("gscCalUser", "AUXILIARY",
|
||||
[]string{"gscCalEnabled"},
|
||||
[]string{"gscCalDefaultCalendar", "gscCalTimezone", "gscCalFreeBusyPublic", "gscCalDelegates"},
|
||||
"calendar")
|
||||
|
||||
r.addObjectClass("gscTelUser", "AUXILIARY",
|
||||
[]string{"gscTelEnabled"},
|
||||
[]string{"gscTelExtension", "gscTelDID", "gscTelVoicemailEnabled", "gscTelVoicemailPin", "gscTelCallForward", "gscTelCallGroup", "gscTelRecordCalls"},
|
||||
"telephony")
|
||||
|
||||
r.addObjectClass("gscContactsUser", "AUXILIARY",
|
||||
[]string{"gscContactsEnabled"},
|
||||
[]string{"gscContactsShared", "gscContactsMaxContacts", "gscContactsExportEnabled"},
|
||||
"contacts")
|
||||
|
||||
r.addObjectClass("gscAIUser", "AUXILIARY",
|
||||
[]string{"gscAIEnabled"},
|
||||
[]string{"gscAIModel", "gscAIMaxTokens", "gscAIFeatures", "gscAIUsageQuota"},
|
||||
"ai")
|
||||
|
||||
r.addObjectClass("gscDlpUser", "AUXILIARY",
|
||||
[]string{"gscDlpEnabled"},
|
||||
[]string{"gscDlpPolicyDN", "gscDlpExempt"},
|
||||
"dlp")
|
||||
|
||||
r.addObjectClass("gscSensitivityUser", "AUXILIARY",
|
||||
[]string{"gscSensitivityEnabled"},
|
||||
[]string{"gscSensitivityDefaultLabel"},
|
||||
"sensitivity")
|
||||
|
||||
r.addObjectClass("gscEncryptionUser", "AUXILIARY",
|
||||
[]string{"gscEncryptionEnabled"},
|
||||
[]string{"gscEncryptionKeyDN"},
|
||||
"encryption")
|
||||
|
||||
r.addObjectClass("gscRetentionUser", "AUXILIARY",
|
||||
[]string{"gscRetentionEnabled"},
|
||||
[]string{"gscRetentionPolicyDN"},
|
||||
"retention")
|
||||
|
||||
r.addObjectClass("gscEDiscoveryUser", "AUXILIARY",
|
||||
[]string{"gscEDiscoveryEnabled"},
|
||||
[]string{"gscEDiscoveryCustodian"},
|
||||
"ediscovery")
|
||||
|
||||
r.addObjectClass("gscAdvancedAuditUser", "AUXILIARY",
|
||||
[]string{"gscAuditEnabled"},
|
||||
[]string{"gscAuditLevel"},
|
||||
"audit")
|
||||
|
||||
r.addObjectClass("gscIAMUser", "AUXILIARY",
|
||||
[]string{"gscIAMEnabled"},
|
||||
[]string{"gscIAMMFARequired", "gscIAMMFAMethod", "gscIAMPasswordPolicy", "gscIAMSessionTimeout", "gscIAMMaxSessions", "gscIAMIPRestrictions", "gscIAMRiskLevel"},
|
||||
"iam")
|
||||
|
||||
r.addObjectClass("gscCollaborationUser", "AUXILIARY",
|
||||
[]string{"gscCollabEnabled"},
|
||||
[]string{"gscCollabTeamsEnabled", "gscCollabChannelsEnabled", "gscCollabExternalEnabled"},
|
||||
"collaboration")
|
||||
|
||||
r.addObjectClass("gscBarrierUser", "AUXILIARY",
|
||||
[]string{"gscBarrierEnabled"},
|
||||
[]string{"gscBarrierSegmentDN"},
|
||||
"barriers")
|
||||
|
||||
r.addObjectClass("gscGuestUser", "AUXILIARY",
|
||||
[]string{"gscGuestEnabled"},
|
||||
[]string{"gscGuestType", "gscGuestOrganization", "gscGuestExternalEmail", "gscGuestVerified", "gscGuestFederatedIdpDN", "gscGuestFederatedId", "gscGuestAccessScope", "gscGuestPermissionLevel", "gscGuestResourceDN", "gscGuestInvitedBy", "gscGuestInvitedAt", "gscGuestExpiresAt", "gscGuestLastAccessAt", "gscGuestAccessCount"},
|
||||
"guest")
|
||||
|
||||
r.addObjectClass("gscKmsUser", "AUXILIARY",
|
||||
[]string{"gscKmsEnabled"},
|
||||
[]string{"gscKmsRole", "gscKmsKeyAccess", "gscKmsMaxKeys", "gscKmsAllowedAlgorithms", "gscKmsAllowedOperations", "gscKmsApprovalRequired", "gscKmsAuditEnabled", "gscKmsLastKeyAccess", "gscKmsKeyCount", "gscKmsPolicyDN", "gscKmsHsmAccess"},
|
||||
"kms-user")
|
||||
|
||||
// ── STRUCTURAL entity objectClasses (18) ────────────────────
|
||||
|
||||
r.addObjectClass("gscTenant", "STRUCTURAL",
|
||||
[]string{"gscTenantId", "gscTenantName"},
|
||||
[]string{"gscTenantDomain", "gscTenantStatus", "gscTenantQuota", "gscTenantMaxUsers", "gscTenantCreatedAt", "gscTenantServices", "gscTenantAdminDN", "gscTenantParentDN", "gscDescription", "gscEnabled", "gscNotes", "gscCreatedAt", "gscModifiedAt", "gscCreatedBy", "gscModifiedBy"},
|
||||
"tenant")
|
||||
|
||||
r.addObjectClass("gscResource", "STRUCTURAL",
|
||||
[]string{"gscResourceId", "gscResourceName", "gscResourceType"},
|
||||
[]string{"gscResourceEmail", "gscResourceCapacity", "gscResourceLocation", "gscResourceBookable", "gscResourceApprovalRequired", "gscResourceOwnerDN", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"resource")
|
||||
|
||||
r.addObjectClass("gscDlpPolicy", "STRUCTURAL",
|
||||
[]string{"gscDlpPolicyName"},
|
||||
[]string{"gscDlpPolicyType", "gscDlpPolicyRules", "gscDlpPolicyAction", "gscDlpPolicyPriority", "gscDlpPolicyScope", "gscDlpPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"dlp")
|
||||
|
||||
r.addObjectClass("gscSensitivityLabel", "STRUCTURAL",
|
||||
[]string{"gscSensitivityLabelName"},
|
||||
[]string{"gscSensitivityLabelPriority", "gscSensitivityLabelColor", "gscSensitivityLabelTooltip", "gscSensitivityLabelScope", "gscSensitivityEncryptionRequired", "gscSensitivityWatermark", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"sensitivity")
|
||||
|
||||
r.addObjectClass("gscEncryptionPolicy", "STRUCTURAL",
|
||||
[]string{"gscEncryptionPolicyName"},
|
||||
[]string{"gscEncryptionPolicyType", "gscEncryptionAlgorithm", "gscEncryptionKeyLength", "gscEncryptionScope", "gscEncryptionPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"encryption")
|
||||
|
||||
r.addObjectClass("gscRetentionPolicy", "STRUCTURAL",
|
||||
[]string{"gscRetentionPolicyName"},
|
||||
[]string{"gscRetentionPolicyType", "gscRetentionDuration", "gscRetentionAction", "gscRetentionScope", "gscRetentionPolicyStatus", "gscRetentionExcludeFolders", "gscRetentionLegalHold", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"retention")
|
||||
|
||||
r.addObjectClass("gscEDiscoveryCase", "STRUCTURAL",
|
||||
[]string{"gscEDiscoveryCaseName"},
|
||||
[]string{"gscEDiscoveryCaseStatus", "gscEDiscoveryCaseCreatedAt", "gscEDiscoveryCaseClosedAt", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"ediscovery")
|
||||
|
||||
r.addObjectClass("gscEDiscoveryHold", "STRUCTURAL",
|
||||
[]string{"gscEDiscoveryHoldName"},
|
||||
[]string{"gscEDiscoveryHoldScope", "gscEDiscoveryHoldStatus", "gscEDiscoveryHoldCreatedAt", "gscEDiscoverySearchQuery", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"ediscovery")
|
||||
|
||||
r.addObjectClass("gscAuditPolicy", "STRUCTURAL",
|
||||
[]string{"gscAuditPolicyName"},
|
||||
[]string{"gscAuditPolicyScope", "gscAuditPolicyActions", "gscAuditPolicyStatus", "gscAuditRetentionDays", "gscAuditAlertEnabled", "gscAuditAlertRecipients", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"audit")
|
||||
|
||||
r.addObjectClass("gscConditionalAccessPolicy", "STRUCTURAL",
|
||||
[]string{"gscIAMCAPolicyName"},
|
||||
[]string{"gscIAMCAPolicyConditions", "gscIAMCAPolicyActions", "gscIAMCAPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"iam")
|
||||
|
||||
r.addObjectClass("gscCollaborationPolicy", "STRUCTURAL",
|
||||
[]string{"gscCollabPolicyName"},
|
||||
[]string{"gscCollabPolicyScope", "gscCollabPolicyActions", "gscCollabPolicyStatus", "gscCollabMaxTeamSize", "gscCollabGuestAccessEnabled", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"collaboration")
|
||||
|
||||
r.addObjectClass("gscBarrierSegment", "STRUCTURAL",
|
||||
[]string{"gscBarrierSegmentName"},
|
||||
[]string{"gscBarrierSegmentMembers", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"barriers")
|
||||
|
||||
r.addObjectClass("gscBarrierPolicy", "STRUCTURAL",
|
||||
[]string{"gscBarrierPolicyName"},
|
||||
[]string{"gscBarrierPolicyType", "gscBarrierPolicySegments", "gscBarrierPolicyAction", "gscBarrierPolicyStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"barriers")
|
||||
|
||||
r.addObjectClass("gscGuestPolicy", "STRUCTURAL",
|
||||
[]string{"gscGuestPolicyName"},
|
||||
[]string{"gscGuestPolicyScope", "gscGuestPolicyAction", "gscGuestPolicyStatus", "gscGuestMaxDuration", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"guest")
|
||||
|
||||
r.addObjectClass("gscFederatedIdp", "STRUCTURAL",
|
||||
[]string{"gscGuestIdpName"},
|
||||
[]string{"gscGuestIdpType", "gscGuestIdpEntityId", "gscGuestIdpMetadataURL", "gscGuestIdpDomains", "gscGuestIdpStatus", "gscDescription", "gscEnabled", "gscCreatedAt", "gscModifiedAt"},
|
||||
"guest")
|
||||
|
||||
r.addObjectClass("gscManagedKey", "STRUCTURAL",
|
||||
[]string{"gscKeyId", "gscKeyName", "gscKeyAlgorithm"},
|
||||
[]string{"gscKeyLength", "gscKeyType", "gscKeyStatus", "gscKeyCreatedAt", "gscKeyExpiresAt", "gscKeyRotatedAt", "gscKeyOwnerDN", "gscKeyTenantDN", "gscKeyOperations", "gscKeyMaterial", "gscKeyPublicKey", "gscKeyFingerprint", "gscKeyVersion", "gscKeyPreviousVersionDN", "gscKeyHsmBacked", "gscKeyHsmSlot", "gscKeyAutoRotate", "gscKeyRotationDays"},
|
||||
"managed-key")
|
||||
|
||||
r.addObjectClass("gscKmsPolicy", "STRUCTURAL",
|
||||
[]string{"gscKmsPolicyId", "gscKmsPolicyName"},
|
||||
[]string{"gscKmsPolicyType", "gscKmsPolicyEffect", "gscKmsPolicyPrincipalDN", "gscKmsPolicyResourceDN", "gscKmsPolicyOperations", "gscKmsPolicyConditions", "gscKmsPolicyPriority", "gscKmsPolicyStatus", "gscKmsPolicyCreatedAt", "gscKmsPolicyModifiedAt", "gscKmsPolicyTenantDN", "gscKmsPolicyMaxKeyAge", "gscKmsPolicyRequireHsm"},
|
||||
"kms-policy")
|
||||
|
||||
r.addObjectClass("gscHsmConfig", "STRUCTURAL",
|
||||
[]string{"gscHsmConfigId", "gscHsmConfigName"},
|
||||
[]string{"gscHsmConfigType", "gscHsmConfigVendor", "gscHsmConfigModel", "gscHsmConfigConnectionString", "gscHsmConfigSlots", "gscHsmConfigStatus", "gscHsmConfigMaxKeys", "gscHsmConfigTenantDN"},
|
||||
"hsm-config")
|
||||
|
||||
// ── AUXILIARY object objectClasses (6) ──────────────────────
|
||||
|
||||
r.addObjectClass("gscAuditObject", "AUXILIARY",
|
||||
[]string{"gscAuditEnabled"},
|
||||
[]string{"gscAuditLevel", "gscAuditPolicyName"},
|
||||
"audit")
|
||||
|
||||
r.addObjectClass("gscMeetingRoom", "AUXILIARY",
|
||||
[]string{"gscResourceId", "gscResourceType"},
|
||||
[]string{"gscResourceCapacity", "gscResourceLocation", "gscResourceBookable"},
|
||||
"resource")
|
||||
|
||||
r.addObjectClass("gscSharedMailbox", "AUXILIARY",
|
||||
[]string{"gscMailEnabled"},
|
||||
[]string{"gscMailQuota", "gscMailAlias", "gscMailDomain"},
|
||||
"mail")
|
||||
|
||||
r.addObjectClass("gscEquipment", "AUXILIARY",
|
||||
[]string{"gscResourceId", "gscResourceType"},
|
||||
[]string{"gscResourceName", "gscResourceLocation", "gscResourceBookable"},
|
||||
"resource")
|
||||
|
||||
r.addObjectClass("gscKmsTenant", "AUXILIARY",
|
||||
[]string{"gscKmsTenantEnabled"},
|
||||
[]string{"gscKmsTenantMaxKeys", "gscKmsTenantAllowedAlgorithms", "gscKmsTenantKeyRotationDays", "gscKmsTenantHsmEnabled", "gscKmsTenantHsmPartition", "gscKmsTenantAutoRotate", "gscKmsTenantKeyCount", "gscKmsTenantQuota", "gscKmsTenantDefaultAlgorithm", "gscKmsTenantDefaultKeyLength", "gscKmsTenantPolicyDN"},
|
||||
"kms-tenant")
|
||||
}
|
||||
273
internal/schema/registry.go
Normal file
273
internal/schema/registry.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registry is the central schema registry for all GoSec LDAP attributes,
|
||||
// objectClasses, and entity types.
|
||||
type Registry struct {
|
||||
attrs map[string]*AttrDef // ldapName → AttrDef
|
||||
attrsByJSON map[string]*AttrDef // "domain:jsonName" → AttrDef
|
||||
domainAttrs map[string][]*AttrDef // domain → list of attrs
|
||||
objectClasses map[string]*ObjectClassDef // OC name → ObjectClassDef
|
||||
domainOC map[string]string // domain → auxiliary user OC name
|
||||
entityTypes map[string]*EntityTypeDef // entity name → EntityTypeDef
|
||||
}
|
||||
|
||||
// NewRegistry creates and populates the schema registry
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{
|
||||
attrs: make(map[string]*AttrDef),
|
||||
attrsByJSON: make(map[string]*AttrDef),
|
||||
domainAttrs: make(map[string][]*AttrDef),
|
||||
objectClasses: make(map[string]*ObjectClassDef),
|
||||
domainOC: make(map[string]string),
|
||||
entityTypes: make(map[string]*EntityTypeDef),
|
||||
}
|
||||
r.registerAttributes()
|
||||
r.registerObjectClasses()
|
||||
r.registerEntities()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Registry) addAttr(ldapName, jsonName string, typ AttrType, domain string, readOnly bool) {
|
||||
def := &AttrDef{
|
||||
LDAPName: ldapName,
|
||||
JSONName: jsonName,
|
||||
Type: typ,
|
||||
Domain: domain,
|
||||
ReadOnly: readOnly,
|
||||
}
|
||||
r.attrs[ldapName] = def
|
||||
r.attrsByJSON[domain+":"+jsonName] = def
|
||||
r.domainAttrs[domain] = append(r.domainAttrs[domain], def)
|
||||
}
|
||||
|
||||
func (r *Registry) addObjectClass(name, kind string, must, may []string, domain string) {
|
||||
r.objectClasses[name] = &ObjectClassDef{
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Must: must,
|
||||
May: may,
|
||||
Domain: domain,
|
||||
}
|
||||
// Map domain → auxiliary user objectClass (first AUXILIARY wins)
|
||||
if kind == "AUXILIARY" {
|
||||
if _, exists := r.domainOC[domain]; !exists {
|
||||
r.domainOC[domain] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) addEntityType(name, description string, objectClasses []string, baseDN, rdnAttr, searchFilter, domain string, requiredAttrs []string) {
|
||||
r.entityTypes[name] = &EntityTypeDef{
|
||||
Name: name,
|
||||
Description: description,
|
||||
ObjectClasses: objectClasses,
|
||||
BaseDN: baseDN,
|
||||
RDNAttribute: rdnAttr,
|
||||
SearchFilter: searchFilter,
|
||||
Domain: domain,
|
||||
RequiredAttrs: requiredAttrs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAttr returns an attribute definition by LDAP name
|
||||
func (r *Registry) GetAttr(ldapName string) *AttrDef {
|
||||
return r.attrs[ldapName]
|
||||
}
|
||||
|
||||
// GetAttrByJSON returns an attribute definition by domain and JSON name
|
||||
func (r *Registry) GetAttrByJSON(domain, jsonName string) *AttrDef {
|
||||
return r.attrsByJSON[domain+":"+jsonName]
|
||||
}
|
||||
|
||||
// AttrsForDomain returns all attribute definitions for a domain
|
||||
func (r *Registry) AttrsForDomain(domain string) []*AttrDef {
|
||||
return r.domainAttrs[domain]
|
||||
}
|
||||
|
||||
// AllDomains returns all registered domain names
|
||||
func (r *Registry) AllDomains() []string {
|
||||
domains := make([]string, 0, len(r.domainAttrs))
|
||||
for d := range r.domainAttrs {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
// AllUserAttrs returns all gsc* LDAP attribute names for user search
|
||||
func (r *Registry) AllUserAttrs() []string {
|
||||
attrs := make([]string, 0, len(r.attrs))
|
||||
for name := range r.attrs {
|
||||
attrs = append(attrs, name)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// UserOCForDomain returns the auxiliary objectClass name for a user service domain
|
||||
func (r *Registry) UserOCForDomain(domain string) string {
|
||||
return r.domainOC[domain]
|
||||
}
|
||||
|
||||
// RequiredOCsForAttrs determines which objectClasses are needed for a set of LDAP attributes
|
||||
func (r *Registry) RequiredOCsForAttrs(ldapAttrNames []string) []string {
|
||||
needed := make(map[string]bool)
|
||||
attrSet := make(map[string]bool, len(ldapAttrNames))
|
||||
for _, a := range ldapAttrNames {
|
||||
attrSet[a] = true
|
||||
}
|
||||
|
||||
for _, oc := range r.objectClasses {
|
||||
if oc.Kind != "AUXILIARY" {
|
||||
continue
|
||||
}
|
||||
for _, must := range oc.Must {
|
||||
if attrSet[must] {
|
||||
needed[oc.Name] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needed[oc.Name] {
|
||||
continue
|
||||
}
|
||||
for _, may := range oc.May {
|
||||
if attrSet[may] {
|
||||
needed[oc.Name] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(needed))
|
||||
for name := range needed {
|
||||
result = append(result, name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetObjectClass returns an objectClass definition by name
|
||||
func (r *Registry) GetObjectClass(name string) *ObjectClassDef {
|
||||
return r.objectClasses[name]
|
||||
}
|
||||
|
||||
// LDAPValueToGo converts LDAP string values to Go typed values based on attribute type
|
||||
func (r *Registry) LDAPValueToGo(attr *AttrDef, values []string) interface{} {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch attr.Type {
|
||||
case AttrString, AttrDN:
|
||||
return values[0]
|
||||
case AttrStringMulti, AttrDNMulti:
|
||||
return values
|
||||
case AttrInt:
|
||||
if v, err := strconv.Atoi(values[0]); err == nil {
|
||||
return v
|
||||
}
|
||||
return values[0]
|
||||
case AttrBool:
|
||||
return strings.EqualFold(values[0], "TRUE")
|
||||
case AttrTime:
|
||||
// GeneralizedTime format: 20060102150405Z
|
||||
if t, err := time.Parse("20060102150405Z", values[0]); err == nil {
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
return values[0]
|
||||
default:
|
||||
return values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// GoValueToLDAP converts a Go value to LDAP string(s) based on attribute type
|
||||
func (r *Registry) GoValueToLDAP(attr *AttrDef, value interface{}) ([]string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch attr.Type {
|
||||
case AttrString, AttrDN:
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("attribute %s expects string, got %T", attr.LDAPName, value)
|
||||
}
|
||||
return []string{s}, nil
|
||||
|
||||
case AttrStringMulti, AttrDNMulti:
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v, nil
|
||||
case []interface{}:
|
||||
result := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("attribute %s expects string array, got %T in array", attr.LDAPName, item)
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("attribute %s expects string array, got %T", attr.LDAPName, value)
|
||||
}
|
||||
|
||||
case AttrInt:
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return []string{strconv.Itoa(int(v))}, nil
|
||||
case int:
|
||||
return []string{strconv.Itoa(v)}, nil
|
||||
case string:
|
||||
return []string{v}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("attribute %s expects int, got %T", attr.LDAPName, value)
|
||||
}
|
||||
|
||||
case AttrBool:
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
if v {
|
||||
return []string{"TRUE"}, nil
|
||||
}
|
||||
return []string{"FALSE"}, nil
|
||||
case string:
|
||||
return []string{strings.ToUpper(v)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("attribute %s expects bool, got %T", attr.LDAPName, value)
|
||||
}
|
||||
|
||||
case AttrTime:
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("attribute %s expects time string, got %T", attr.LDAPName, value)
|
||||
}
|
||||
// Accept RFC3339 and convert to GeneralizedTime
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return []string{t.UTC().Format("20060102150405Z")}, nil
|
||||
}
|
||||
// Already GeneralizedTime format
|
||||
return []string{s}, nil
|
||||
|
||||
default:
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("attribute %s: unsupported type %T", attr.LDAPName, value)
|
||||
}
|
||||
return []string{s}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetEntityType returns an entity type definition by name
|
||||
func (r *Registry) GetEntityType(name string) *EntityTypeDef {
|
||||
return r.entityTypes[name]
|
||||
}
|
||||
|
||||
// AllEntityTypes returns all registered entity type definitions
|
||||
func (r *Registry) AllEntityTypes() map[string]*EntityTypeDef {
|
||||
return r.entityTypes
|
||||
}
|
||||
44
internal/schema/types.go
Normal file
44
internal/schema/types.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package schema
|
||||
|
||||
// AttrType represents the LDAP attribute value type
|
||||
type AttrType int
|
||||
|
||||
const (
|
||||
AttrString AttrType = iota // Single-value string
|
||||
AttrStringMulti // Multi-value string
|
||||
AttrInt // Integer (stored as string in LDAP)
|
||||
AttrBool // Boolean (TRUE/FALSE in LDAP)
|
||||
AttrDN // DN single
|
||||
AttrDNMulti // DN multi
|
||||
AttrTime // GeneralizedTime
|
||||
)
|
||||
|
||||
// AttrDef defines an LDAP attribute with its JSON mapping and type info
|
||||
type AttrDef struct {
|
||||
LDAPName string // LDAP attribute name (e.g. "gscMailEnabled")
|
||||
JSONName string // JSON field name (e.g. "enabled")
|
||||
Type AttrType // Value type for conversion
|
||||
Domain string // Service domain (e.g. "mail", "calendar")
|
||||
ReadOnly bool // If true, not settable via API
|
||||
}
|
||||
|
||||
// ObjectClassDef defines an LDAP objectClass
|
||||
type ObjectClassDef struct {
|
||||
Name string // ObjectClass name (e.g. "gscMailUser")
|
||||
Kind string // "AUXILIARY" or "STRUCTURAL"
|
||||
Must []string // Required LDAP attributes
|
||||
May []string // Optional LDAP attributes
|
||||
Domain string // Service domain this OC belongs to
|
||||
}
|
||||
|
||||
// EntityTypeDef defines a standalone LDAP entity type for CRUD operations
|
||||
type EntityTypeDef struct {
|
||||
Name string // URL-safe name (e.g. "tenant", "dlp-policy")
|
||||
Description string // Human-readable description
|
||||
ObjectClasses []string // Required objectClasses
|
||||
BaseDN string // Relative base DN (appended to LDAP base)
|
||||
RDNAttribute string // Attribute used as RDN (e.g. "cn", "gscTenantId")
|
||||
SearchFilter string // LDAP search filter for listing
|
||||
Domain string // Logical domain grouping
|
||||
RequiredAttrs []string // Attributes required on create
|
||||
}
|
||||
449
internal/service/carddav.go
Normal file
449
internal/service/carddav.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// CardDAVService handles CardDAV principal, address book, and contact operations
|
||||
type CardDAVService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewCardDAVService creates a new CardDAV service
|
||||
func NewCardDAVService(pool *pgxpool.Pool, logger zerolog.Logger) *CardDAVService {
|
||||
return &CardDAVService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "carddav").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Principals ---
|
||||
|
||||
// ListPrincipals lists all principals
|
||||
func (s *CardDAVService) ListPrincipals(ctx context.Context) ([]types.CardDAVPrincipal, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, uri, email, displayname FROM principals ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
principals := make([]types.CardDAVPrincipal, 0)
|
||||
for rows.Next() {
|
||||
var p types.CardDAVPrincipal
|
||||
var email, displayName *string
|
||||
if err := rows.Scan(&p.ID, &p.URI, &email, &displayName); err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
if email != nil {
|
||||
p.Email = *email
|
||||
}
|
||||
if displayName != nil {
|
||||
p.DisplayName = *displayName
|
||||
}
|
||||
principals = append(principals, p)
|
||||
}
|
||||
return principals, nil
|
||||
}
|
||||
|
||||
// GetPrincipal gets a principal by username
|
||||
func (s *CardDAVService) GetPrincipal(ctx context.Context, username string) (*types.CardDAVPrincipal, error) {
|
||||
uri := "principals/" + username
|
||||
|
||||
var p types.CardDAVPrincipal
|
||||
var email, displayName *string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, uri, email, displayname FROM principals WHERE uri = $1`, uri).
|
||||
Scan(&p.ID, &p.URI, &email, &displayName)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
if email != nil {
|
||||
p.Email = *email
|
||||
}
|
||||
if displayName != nil {
|
||||
p.DisplayName = *displayName
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// CreatePrincipal creates a new principal
|
||||
func (s *CardDAVService) CreatePrincipal(ctx context.Context, req *types.CardDAVPrincipalCreate) (*types.CardDAVPrincipal, error) {
|
||||
uri := "principals/" + req.Username
|
||||
|
||||
var id int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO principals (uri, email, displayname) VALUES ($1, $2, $3) RETURNING id`,
|
||||
uri, nilIfEmpty(req.Email), nilIfEmpty(req.DisplayName)).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Str("username", req.Username).Int("id", id).Msg("Created principal")
|
||||
return s.GetPrincipal(ctx, req.Username)
|
||||
}
|
||||
|
||||
// DeletePrincipal deletes a principal and cascades to address books and contacts
|
||||
func (s *CardDAVService) DeletePrincipal(ctx context.Context, username string) error {
|
||||
uri := "principals/" + username
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Delete contacts and changes for all address books owned by this principal
|
||||
_, err = tx.Exec(ctx,
|
||||
`DELETE FROM cards WHERE addressbookid IN (SELECT id FROM addressbooks WHERE principaluri = $1)`, uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete contacts failed: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`DELETE FROM addressbookchanges WHERE addressbookid IN (SELECT id FROM addressbooks WHERE principaluri = $1)`, uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete changes failed: %w", err)
|
||||
}
|
||||
|
||||
// Delete address books
|
||||
_, err = tx.Exec(ctx,
|
||||
`DELETE FROM addressbooks WHERE principaluri = $1`, uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete addressbooks failed: %w", err)
|
||||
}
|
||||
|
||||
// Delete principal
|
||||
ct, err := tx.Exec(ctx, `DELETE FROM principals WHERE uri = $1`, uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete principal failed: %w", err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return fmt.Errorf("principal not found")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Str("username", username).Msg("Deleted principal with cascade")
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Address Books ---
|
||||
|
||||
// ListAddressBooks lists address books, optionally filtered by principal
|
||||
func (s *CardDAVService) ListAddressBooks(ctx context.Context, principal string) ([]types.AddressBook, error) {
|
||||
query := `SELECT id, principaluri, displayname, uri, description, synctoken FROM addressbooks`
|
||||
args := []interface{}{}
|
||||
|
||||
if principal != "" {
|
||||
query += ` WHERE principaluri = $1`
|
||||
args = append(args, "principals/"+principal)
|
||||
}
|
||||
query += ` ORDER BY id`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
books := make([]types.AddressBook, 0)
|
||||
for rows.Next() {
|
||||
var ab types.AddressBook
|
||||
var description *string
|
||||
if err := rows.Scan(&ab.ID, &ab.PrincipalURI, &ab.DisplayName, &ab.URI, &description, &ab.SyncToken); err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
if description != nil {
|
||||
ab.Description = *description
|
||||
}
|
||||
books = append(books, ab)
|
||||
}
|
||||
return books, nil
|
||||
}
|
||||
|
||||
// GetAddressBook gets an address book by ID
|
||||
func (s *CardDAVService) GetAddressBook(ctx context.Context, id int) (*types.AddressBook, error) {
|
||||
var ab types.AddressBook
|
||||
var description *string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, principaluri, displayname, uri, description, synctoken FROM addressbooks WHERE id = $1`, id).
|
||||
Scan(&ab.ID, &ab.PrincipalURI, &ab.DisplayName, &ab.URI, &description, &ab.SyncToken)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
if description != nil {
|
||||
ab.Description = *description
|
||||
}
|
||||
return &ab, nil
|
||||
}
|
||||
|
||||
// CreateAddressBook creates a new address book
|
||||
func (s *CardDAVService) CreateAddressBook(ctx context.Context, req *types.AddressBookCreate) (*types.AddressBook, error) {
|
||||
var id int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO addressbooks (principaluri, displayname, uri, description, synctoken)
|
||||
VALUES ($1, $2, $3, $4, 1) RETURNING id`,
|
||||
req.PrincipalURI, req.DisplayName, req.URI, nilIfEmpty(req.Description)).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Int("id", id).Str("uri", req.URI).Msg("Created address book")
|
||||
return s.GetAddressBook(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateAddressBook updates an address book
|
||||
func (s *CardDAVService) UpdateAddressBook(ctx context.Context, id int, req *types.AddressBookUpdate) (*types.AddressBook, error) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.DisplayName != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("displayname = $%d", argIdx))
|
||||
args = append(args, *req.DisplayName)
|
||||
argIdx++
|
||||
}
|
||||
if req.Description != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("description = $%d", argIdx))
|
||||
args = append(args, *req.Description)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetAddressBook(ctx, id)
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE addressbooks SET %s WHERE id = $%d",
|
||||
join(setClauses, ", "), argIdx)
|
||||
|
||||
ct, err := s.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return s.GetAddressBook(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteAddressBook deletes an address book and its contacts
|
||||
func (s *CardDAVService) DeleteAddressBook(ctx context.Context, id int) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `DELETE FROM cards WHERE addressbookid = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete contacts failed: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `DELETE FROM addressbookchanges WHERE addressbookid = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete changes failed: %w", err)
|
||||
}
|
||||
|
||||
ct, err := tx.Exec(ctx, `DELETE FROM addressbooks WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete addressbook failed: %w", err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return fmt.Errorf("address book not found")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Int("id", id).Msg("Deleted address book with contacts")
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Contacts ---
|
||||
|
||||
// ListContacts lists contacts in an address book (metadata only, no carddata)
|
||||
func (s *CardDAVService) ListContacts(ctx context.Context, addressBookID int) ([]types.Contact, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, addressbookid, uri, lastmodified, etag, size
|
||||
FROM cards WHERE addressbookid = $1 ORDER BY id`, addressBookID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
contacts := make([]types.Contact, 0)
|
||||
for rows.Next() {
|
||||
var c types.Contact
|
||||
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.LastModified, &c.ETag, &c.Size); err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
// GetContact gets a contact by address book ID and URI (returns full carddata)
|
||||
func (s *CardDAVService) GetContact(ctx context.Context, addressBookID int, uri string) (*types.Contact, error) {
|
||||
var c types.Contact
|
||||
var cardData []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, addressbookid, carddata, uri, lastmodified, etag, size
|
||||
FROM cards WHERE addressbookid = $1 AND uri = $2`, addressBookID, uri).
|
||||
Scan(&c.ID, &c.AddressBookID, &cardData, &c.URI, &c.LastModified, &c.ETag, &c.Size)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
c.CardData = string(cardData)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateContact creates a new contact in an address book
|
||||
func (s *CardDAVService) CreateContact(ctx context.Context, addressBookID int, req *types.ContactCreate) (*types.Contact, error) {
|
||||
etag := computeETag(req.CardData)
|
||||
size := len(req.CardData)
|
||||
lastModified := int(time.Now().Unix())
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO cards (addressbookid, carddata, uri, lastmodified, etag, size)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
addressBookID, []byte(req.CardData), req.URI, lastModified, etag, size)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
// Record change and bump sync token (operation 1 = add)
|
||||
if err := addChange(ctx, tx, addressBookID, req.URI, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Int("addressbookId", addressBookID).Str("uri", req.URI).Msg("Created contact")
|
||||
return s.GetContact(ctx, addressBookID, req.URI)
|
||||
}
|
||||
|
||||
// UpdateContact updates a contact's vCard data
|
||||
func (s *CardDAVService) UpdateContact(ctx context.Context, addressBookID int, uri string, req *types.ContactUpdate) (*types.Contact, error) {
|
||||
etag := computeETag(req.CardData)
|
||||
size := len(req.CardData)
|
||||
lastModified := int(time.Now().Unix())
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
ct, err := tx.Exec(ctx,
|
||||
`UPDATE cards SET carddata = $1, lastmodified = $2, etag = $3, size = $4
|
||||
WHERE addressbookid = $5 AND uri = $6`,
|
||||
[]byte(req.CardData), lastModified, etag, size, addressBookID, uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Record change and bump sync token (operation 2 = modify)
|
||||
if err := addChange(ctx, tx, addressBookID, uri, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Int("addressbookId", addressBookID).Str("uri", uri).Msg("Updated contact")
|
||||
return s.GetContact(ctx, addressBookID, uri)
|
||||
}
|
||||
|
||||
// DeleteContact deletes a contact from an address book
|
||||
func (s *CardDAVService) DeleteContact(ctx context.Context, addressBookID int, uri string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
ct, err := tx.Exec(ctx,
|
||||
`DELETE FROM cards WHERE addressbookid = $1 AND uri = $2`, addressBookID, uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return fmt.Errorf("contact not found")
|
||||
}
|
||||
|
||||
// Record change and bump sync token (operation 3 = delete)
|
||||
if err := addChange(ctx, tx, addressBookID, uri, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info().Int("addressbookId", addressBookID).Str("uri", uri).Msg("Deleted contact")
|
||||
return nil
|
||||
}
|
||||
|
||||
// addChange records a change in addressbookchanges and bumps the sync token.
|
||||
// This is critical for CardDAV sync — without it, clients won't see incremental changes.
|
||||
// Operations: 1=add, 2=modify, 3=delete
|
||||
func addChange(ctx context.Context, tx pgx.Tx, addressBookID int, uri string, operation int) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO addressbookchanges (uri, synctoken, addressbookid, operation)
|
||||
SELECT $1, synctoken, $2, $3 FROM addressbooks WHERE id = $2`,
|
||||
uri, addressBookID, operation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record change failed: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`UPDATE addressbooks SET synctoken = synctoken + 1 WHERE id = $1`, addressBookID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bump synctoken failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeETag computes the ETag for card data (raw MD5 hex, matching sabre/dav DB format)
|
||||
func computeETag(cardData string) string {
|
||||
hash := md5.Sum([]byte(cardData))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
161
internal/service/certificate.go
Normal file
161
internal/service/certificate.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// CertificateService handles EJBCA certificate operations
|
||||
type CertificateService struct {
|
||||
client *client.EJBCAClient
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewCertificateService creates a new certificate service
|
||||
func NewCertificateService(ejbcaClient *client.EJBCAClient, logger zerolog.Logger) *CertificateService {
|
||||
return &CertificateService{
|
||||
client: ejbcaClient,
|
||||
logger: logger.With().Str("service", "certificate").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListCertificates searches for certificates
|
||||
func (s *CertificateService) ListCertificates(search string, limit int) ([]types.Certificate, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
criteria := []client.CertSearchCriterion{}
|
||||
if search != "" {
|
||||
criteria = append(criteria, client.CertSearchCriterion{
|
||||
Property: "QUERY",
|
||||
Value: search,
|
||||
Operation: "LIKE",
|
||||
})
|
||||
}
|
||||
|
||||
certs, err := s.client.SearchCertificates(&client.CertSearchRequest{
|
||||
MaxResults: limit,
|
||||
Criteria: criteria,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]types.Certificate, 0, len(certs))
|
||||
for _, c := range certs {
|
||||
cert := types.Certificate{
|
||||
SerialNumber: c.SerialNumber,
|
||||
SubjectDN: c.SubjectDN,
|
||||
IssuerDN: c.IssuerDN,
|
||||
Status: c.Status,
|
||||
CAName: c.CAName,
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil {
|
||||
cert.NotBefore = t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil {
|
||||
cert.NotAfter = t
|
||||
}
|
||||
result = append(result, cert)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCertificate gets a certificate by serial number
|
||||
func (s *CertificateService) GetCertificate(serialNumber, issuerDN string) (*types.Certificate, error) {
|
||||
c, err := s.client.GetCertificate(issuerDN, serialNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert := &types.Certificate{
|
||||
SerialNumber: c.SerialNumber,
|
||||
SubjectDN: c.SubjectDN,
|
||||
IssuerDN: c.IssuerDN,
|
||||
Status: c.Status,
|
||||
CAName: c.CAName,
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil {
|
||||
cert.NotBefore = t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil {
|
||||
cert.NotAfter = t
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// RequestCertificate requests a new certificate from EJBCA
|
||||
func (s *CertificateService) RequestCertificate(req *types.CertRequest) (*types.Certificate, error) {
|
||||
san := buildSANString(req.SubjectDN, req.SANs)
|
||||
|
||||
enrollReq := &client.CertEnrollRequest{
|
||||
CertificateProfileName: req.CertProfileName,
|
||||
EndEntityProfileName: req.EndEntityName,
|
||||
CAName: req.CAName,
|
||||
Username: req.EndEntityName,
|
||||
Password: "internal",
|
||||
IncludeChain: true,
|
||||
SubjectAltName: san,
|
||||
}
|
||||
|
||||
c, err := s.client.EnrollCertificate(enrollReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert := &types.Certificate{
|
||||
SerialNumber: c.SerialNumber,
|
||||
SubjectDN: c.SubjectDN,
|
||||
IssuerDN: c.IssuerDN,
|
||||
Status: c.Status,
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// buildSANString builds an EJBCA-format SAN string (e.g. "dNSName=foo,dNSName=bar").
|
||||
// If sans is empty, extracts CN from subjectDN as a fallback DNS SAN.
|
||||
func buildSANString(subjectDN string, sans []string) string {
|
||||
if len(sans) > 0 {
|
||||
parts := make([]string, 0, len(sans))
|
||||
for _, s := range sans {
|
||||
if s != "" {
|
||||
parts = append(parts, fmt.Sprintf("dNSName=%s", s))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// Fallback: extract CN from SubjectDN and use as DNS SAN
|
||||
cn := extractCN(subjectDN)
|
||||
if cn != "" {
|
||||
return fmt.Sprintf("dNSName=%s", cn)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractCN extracts the CN value from a SubjectDN string like "CN=foo.bar,O=Org"
|
||||
func extractCN(subjectDN string) string {
|
||||
for _, part := range strings.Split(subjectDN, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "CN=") {
|
||||
return strings.TrimPrefix(part, "CN=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate
|
||||
func (s *CertificateService) RevokeCertificate(serialNumber string, req *types.CertRevoke) error {
|
||||
reason := req.Reason
|
||||
if reason == "" {
|
||||
reason = "UNSPECIFIED"
|
||||
}
|
||||
return s.client.RevokeCertificate(req.IssuerDN, serialNumber, reason)
|
||||
}
|
||||
413
internal/service/database.go
Normal file
413
internal/service/database.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DatabaseService handles tenant and user database operations
|
||||
type DatabaseService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewDatabaseService creates a new database service
|
||||
func NewDatabaseService(pool *pgxpool.Pool, logger zerolog.Logger) *DatabaseService {
|
||||
return &DatabaseService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "database").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListTenants lists tenants with optional filters
|
||||
func (s *DatabaseService) ListTenants(ctx context.Context, params types.ListParams) ([]types.Tenant, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM admin.tenants WHERE 1=1`
|
||||
listQuery := `SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color,
|
||||
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at
|
||||
FROM admin.tenants WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if params.Status != "" {
|
||||
if params.Status == "active" {
|
||||
countQuery += " AND is_active = true"
|
||||
listQuery += " AND is_active = true"
|
||||
} else if params.Status == "inactive" {
|
||||
countQuery += " AND is_active = false"
|
||||
listQuery += " AND is_active = false"
|
||||
}
|
||||
}
|
||||
if params.Search != "" {
|
||||
countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
listQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tenants := make([]types.Tenant, 0)
|
||||
for rows.Next() {
|
||||
var t types.Tenant
|
||||
var metadataJSON []byte
|
||||
if err := rows.Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain,
|
||||
&t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours,
|
||||
&t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &t.Metadata)
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
}
|
||||
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
// GetTenant gets a tenant by ID
|
||||
func (s *DatabaseService) GetTenant(ctx context.Context, id uuid.UUID) (*types.Tenant, error) {
|
||||
var t types.Tenant
|
||||
var metadataJSON []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color,
|
||||
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at
|
||||
FROM admin.tenants WHERE id = $1`, id).
|
||||
Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain,
|
||||
&t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours,
|
||||
&t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &t.Metadata)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// CreateTenant creates a new tenant
|
||||
func (s *DatabaseService) CreateTenant(ctx context.Context, req *types.TenantCreate) (*types.Tenant, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
var metadataJSON []byte
|
||||
if req.Metadata != nil {
|
||||
var err error
|
||||
metadataJSON, err = json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO admin.tenants (id, customer_id, code, name, display_name, domain, logo_url, primary_color,
|
||||
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true, $12, $13, $13)`,
|
||||
id, req.CustomerID, req.Code, req.Name, nilIfEmpty(req.DisplayName), nilIfEmpty(req.Domain),
|
||||
nilIfEmpty(req.LogoURL), nilIfEmpty(req.PrimaryColor),
|
||||
req.MaxUsers, req.MaxStorageGB, req.MaxRecordingHours, metadataJSON, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetTenant(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateTenant updates a tenant
|
||||
func (s *DatabaseService) UpdateTenant(ctx context.Context, id uuid.UUID, req *types.TenantUpdate) (*types.Tenant, error) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Name != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx))
|
||||
args = append(args, *req.Name)
|
||||
argIdx++
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("display_name = $%d", argIdx))
|
||||
args = append(args, *req.DisplayName)
|
||||
argIdx++
|
||||
}
|
||||
if req.Domain != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("domain = $%d", argIdx))
|
||||
args = append(args, *req.Domain)
|
||||
argIdx++
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("logo_url = $%d", argIdx))
|
||||
args = append(args, *req.LogoURL)
|
||||
argIdx++
|
||||
}
|
||||
if req.PrimaryColor != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("primary_color = $%d", argIdx))
|
||||
args = append(args, *req.PrimaryColor)
|
||||
argIdx++
|
||||
}
|
||||
if req.MaxUsers != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_users = $%d", argIdx))
|
||||
args = append(args, *req.MaxUsers)
|
||||
argIdx++
|
||||
}
|
||||
if req.MaxStorageGB != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_storage_gb = $%d", argIdx))
|
||||
args = append(args, *req.MaxStorageGB)
|
||||
argIdx++
|
||||
}
|
||||
if req.MaxRecordingHours != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_recording_hours = $%d", argIdx))
|
||||
args = append(args, *req.MaxRecordingHours)
|
||||
argIdx++
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx))
|
||||
args = append(args, *req.IsActive)
|
||||
argIdx++
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
metadataJSON, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx))
|
||||
args = append(args, metadataJSON)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetTenant(ctx, id)
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx))
|
||||
args = append(args, time.Now().UTC())
|
||||
argIdx++
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE admin.tenants SET %s WHERE id = $%d",
|
||||
join(setClauses, ", "), argIdx)
|
||||
|
||||
_, err := s.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetTenant(ctx, id)
|
||||
}
|
||||
|
||||
// SoftDeleteTenant deactivates a tenant
|
||||
func (s *DatabaseService) SoftDeleteTenant(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE admin.tenants SET is_active = false, updated_at = $1 WHERE id = $2`,
|
||||
time.Now().UTC(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUsers lists users with optional filters
|
||||
func (s *DatabaseService) ListUsers(ctx context.Context, params types.ListParams) ([]types.DBUser, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM admin.users WHERE 1=1`
|
||||
listQuery := `SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status,
|
||||
last_login_at, last_activity_at, metadata, created_at, updated_at
|
||||
FROM admin.users WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if params.Status != "" {
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
listQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, params.Status)
|
||||
argIdx++
|
||||
}
|
||||
if params.Search != "" {
|
||||
countQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
listQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]types.DBUser, 0)
|
||||
for rows.Next() {
|
||||
var u types.DBUser
|
||||
var metadataJSON []byte
|
||||
if err := rows.Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status,
|
||||
&u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &u.Metadata)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// GetUser gets a user by ID
|
||||
func (s *DatabaseService) GetUser(ctx context.Context, id uuid.UUID) (*types.DBUser, error) {
|
||||
var u types.DBUser
|
||||
var metadataJSON []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status,
|
||||
last_login_at, last_activity_at, metadata, created_at, updated_at
|
||||
FROM admin.users WHERE id = $1`, id).
|
||||
Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status,
|
||||
&u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &u.Metadata)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user record
|
||||
func (s *DatabaseService) CreateUser(ctx context.Context, req *types.DBUserCreate) (*types.DBUser, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
var metadataJSON []byte
|
||||
if req.Metadata != nil {
|
||||
var err error
|
||||
metadataJSON, err = json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO admin.users (id, gscsid, first_name, last_name, display_name, email, timezone, locale, status, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $10)`,
|
||||
id, req.GscSID, nilIfEmpty(req.FirstName), nilIfEmpty(req.LastName), nilIfEmpty(req.DisplayName), nilIfEmpty(req.Email), nilIfEmpty(req.Timezone), nilIfEmpty(req.Locale), metadataJSON, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateUser updates a user record
|
||||
func (s *DatabaseService) UpdateUser(ctx context.Context, id uuid.UUID, req *types.DBUserUpdate) (*types.DBUser, error) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Timezone != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("timezone = $%d", argIdx))
|
||||
args = append(args, *req.Timezone)
|
||||
argIdx++
|
||||
}
|
||||
if req.Locale != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("locale = $%d", argIdx))
|
||||
args = append(args, *req.Locale)
|
||||
argIdx++
|
||||
}
|
||||
if req.Status != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
|
||||
args = append(args, *req.Status)
|
||||
argIdx++
|
||||
}
|
||||
if req.LastLoginAt != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("last_login_at = $%d", argIdx))
|
||||
args = append(args, *req.LastLoginAt)
|
||||
argIdx++
|
||||
}
|
||||
if req.LastActivityAt != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("last_activity_at = $%d", argIdx))
|
||||
args = append(args, *req.LastActivityAt)
|
||||
argIdx++
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
metadataJSON, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx))
|
||||
args = append(args, metadataJSON)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx))
|
||||
args = append(args, time.Now().UTC())
|
||||
argIdx++
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE admin.users SET %s WHERE id = $%d",
|
||||
join(setClauses, ", "), argIdx)
|
||||
|
||||
_, err := s.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
// DeactivateUser deactivates a user
|
||||
func (s *DatabaseService) DeactivateUser(ctx context.Context, id uuid.UUID) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE admin.users SET status = 'inactive', updated_at = $1 WHERE id = $2`,
|
||||
now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func join(strs []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range strs {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
324
internal/service/dns.go
Normal file
324
internal/service/dns.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DNSService handles PowerDNS zone and record operations
|
||||
type DNSService struct {
|
||||
client *client.PowerDNSClient
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewDNSService creates a new DNS service
|
||||
func NewDNSService(pdnsClient *client.PowerDNSClient, logger zerolog.Logger) *DNSService {
|
||||
return &DNSService{
|
||||
client: pdnsClient,
|
||||
logger: logger.With().Str("service", "dns").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListZones lists all DNS zones
|
||||
func (s *DNSService) ListZones() ([]types.DNSZone, error) {
|
||||
zones, err := s.client.ListZones()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]types.DNSZone, 0, len(zones))
|
||||
for _, z := range zones {
|
||||
result = append(result, types.DNSZone{
|
||||
ID: z.ID,
|
||||
Name: z.Name,
|
||||
Kind: z.Kind,
|
||||
DNSSec: z.DNSSec,
|
||||
Serial: z.Serial,
|
||||
NotifiedSerial: z.NotifiedSerial,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetZone gets a zone with records
|
||||
func (s *DNSService) GetZone(zoneID string) (*types.DNSZone, error) {
|
||||
z, err := s.client.GetZone(zoneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zone := &types.DNSZone{
|
||||
ID: z.ID,
|
||||
Name: z.Name,
|
||||
Kind: z.Kind,
|
||||
DNSSec: z.DNSSec,
|
||||
Serial: z.Serial,
|
||||
NotifiedSerial: z.NotifiedSerial,
|
||||
SOAEdit: z.SOAEdit,
|
||||
SOAEditAPI: z.SOAEditAPI,
|
||||
}
|
||||
|
||||
records := make([]types.DNSRecord, 0, len(z.RRSets))
|
||||
for _, rr := range z.RRSets {
|
||||
entries := make([]types.DNSRecordEntry, 0, len(rr.Records))
|
||||
for _, r := range rr.Records {
|
||||
entries = append(entries, types.DNSRecordEntry{
|
||||
Content: r.Content,
|
||||
Disabled: r.Disabled,
|
||||
})
|
||||
}
|
||||
records = append(records, types.DNSRecord{
|
||||
Name: rr.Name,
|
||||
Type: rr.Type,
|
||||
TTL: rr.TTL,
|
||||
Records: entries,
|
||||
})
|
||||
}
|
||||
zone.Records = records
|
||||
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
// CreateZone creates a new DNS zone
|
||||
func (s *DNSService) CreateZone(req *types.DNSZoneCreate) (*types.DNSZone, error) {
|
||||
kind := req.Kind
|
||||
if kind == "" {
|
||||
kind = "Native"
|
||||
}
|
||||
|
||||
name := req.Name
|
||||
if !strings.HasSuffix(name, ".") {
|
||||
name += "."
|
||||
}
|
||||
|
||||
z, err := s.client.CreateZone(&client.ZoneCreate{
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Nameservers: req.Nameservers,
|
||||
Masters: req.Masters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.DNSZone{
|
||||
ID: z.ID,
|
||||
Name: z.Name,
|
||||
Kind: z.Kind,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateZone updates zone metadata
|
||||
func (s *DNSService) UpdateZone(zoneID string, req *types.DNSZoneUpdate) error {
|
||||
data := make(map[string]interface{})
|
||||
if req.Kind != nil {
|
||||
data["kind"] = *req.Kind
|
||||
}
|
||||
if req.Masters != nil {
|
||||
data["masters"] = req.Masters
|
||||
}
|
||||
return s.client.UpdateZone(zoneID, data)
|
||||
}
|
||||
|
||||
// DeleteZone deletes a zone
|
||||
func (s *DNSService) DeleteZone(zoneID string) error {
|
||||
return s.client.DeleteZone(zoneID)
|
||||
}
|
||||
|
||||
// NotifyZone sends NOTIFY to slaves
|
||||
func (s *DNSService) NotifyZone(zoneID string) error {
|
||||
return s.client.NotifyZone(zoneID)
|
||||
}
|
||||
|
||||
// ListRecords lists records in a zone
|
||||
func (s *DNSService) ListRecords(zoneID string) ([]types.DNSRecord, error) {
|
||||
zone, err := s.GetZone(zoneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return zone.Records, nil
|
||||
}
|
||||
|
||||
// ChangeRecords applies record changes to a zone using PATCH semantics
|
||||
func (s *DNSService) ChangeRecords(zoneID string, changes []types.DNSRecordChange) error {
|
||||
rrsets := make([]client.RRSet, 0, len(changes))
|
||||
for _, ch := range changes {
|
||||
name := ch.Name
|
||||
if !strings.HasSuffix(name, ".") {
|
||||
name += "."
|
||||
}
|
||||
|
||||
records := make([]client.Record, 0, len(ch.Records))
|
||||
for _, r := range ch.Records {
|
||||
records = append(records, client.Record{
|
||||
Content: r.Content,
|
||||
Disabled: r.Disabled,
|
||||
})
|
||||
}
|
||||
|
||||
ttl := ch.TTL
|
||||
if ttl == 0 {
|
||||
ttl = 3600
|
||||
}
|
||||
|
||||
rrsets = append(rrsets, client.RRSet{
|
||||
Name: name,
|
||||
Type: ch.Type,
|
||||
TTL: ttl,
|
||||
ChangeType: ch.ChangeType,
|
||||
Records: records,
|
||||
})
|
||||
}
|
||||
return s.client.PatchRRSets(zoneID, rrsets)
|
||||
}
|
||||
|
||||
// SetupDomain creates a zone with standard mail DNS records (MX, SPF, DKIM, DMARC)
|
||||
func (s *DNSService) SetupDomain(req *types.DomainSetup) (*types.DNSZone, error) {
|
||||
domain := req.Domain
|
||||
if !strings.HasSuffix(domain, ".") {
|
||||
domain += "."
|
||||
}
|
||||
|
||||
// Create zone first
|
||||
zone, err := s.client.CreateZone(&client.ZoneCreate{
|
||||
Name: domain,
|
||||
Kind: "Native",
|
||||
Nameservers: []string{
|
||||
"ns1.gosec.cloud.",
|
||||
"ns2.gosec.cloud.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create zone: %w", err)
|
||||
}
|
||||
|
||||
// Build standard mail records
|
||||
mxHost := req.MXHost
|
||||
if mxHost == "" {
|
||||
mxHost = "mail.gosec.cloud."
|
||||
}
|
||||
if !strings.HasSuffix(mxHost, ".") {
|
||||
mxHost += "."
|
||||
}
|
||||
|
||||
rrsets := []client.RRSet{
|
||||
{
|
||||
Name: domain,
|
||||
Type: "MX",
|
||||
TTL: 3600,
|
||||
ChangeType: "REPLACE",
|
||||
Records: []client.Record{{Content: "10 " + mxHost}},
|
||||
},
|
||||
}
|
||||
|
||||
// SPF record
|
||||
spf := "v=spf1"
|
||||
if len(req.SPFIncludes) > 0 {
|
||||
for _, inc := range req.SPFIncludes {
|
||||
spf += " include:" + inc
|
||||
}
|
||||
}
|
||||
spf += " mx -all"
|
||||
rrsets = append(rrsets, client.RRSet{
|
||||
Name: domain,
|
||||
Type: "TXT",
|
||||
TTL: 3600,
|
||||
ChangeType: "REPLACE",
|
||||
Records: []client.Record{{Content: fmt.Sprintf(`"%s"`, spf)}},
|
||||
})
|
||||
|
||||
// DKIM record
|
||||
if req.DKIMKey != "" {
|
||||
rrsets = append(rrsets, client.RRSet{
|
||||
Name: "default._domainkey." + domain,
|
||||
Type: "TXT",
|
||||
TTL: 3600,
|
||||
ChangeType: "REPLACE",
|
||||
Records: []client.Record{{Content: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, req.DKIMKey)}},
|
||||
})
|
||||
}
|
||||
|
||||
// DMARC record
|
||||
rrsets = append(rrsets, client.RRSet{
|
||||
Name: "_dmarc." + domain,
|
||||
Type: "TXT",
|
||||
TTL: 3600,
|
||||
ChangeType: "REPLACE",
|
||||
Records: []client.Record{{Content: fmt.Sprintf(`"v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, strings.TrimSuffix(domain, "."))}},
|
||||
})
|
||||
|
||||
if err := s.client.PatchRRSets(zone.ID, rrsets); err != nil {
|
||||
return nil, fmt.Errorf("zone created but record setup failed: %w", err)
|
||||
}
|
||||
|
||||
result := &types.DNSZone{
|
||||
ID: zone.ID,
|
||||
Name: zone.Name,
|
||||
Kind: zone.Kind,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// VerifyDomain checks DNS propagation for a domain
|
||||
func (s *DNSService) VerifyDomain(domain string) (*types.DomainVerifyResult, error) {
|
||||
if !strings.HasSuffix(domain, ".") {
|
||||
domain += "."
|
||||
}
|
||||
|
||||
zone, err := s.client.GetZone(domain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zone not found: %w", err)
|
||||
}
|
||||
|
||||
results := make(map[string]string)
|
||||
hasMX, hasSPF, hasDMARC := false, false, false
|
||||
|
||||
for _, rr := range zone.RRSets {
|
||||
switch {
|
||||
case rr.Type == "MX" && rr.Name == domain:
|
||||
hasMX = true
|
||||
results["MX"] = "OK"
|
||||
case rr.Type == "TXT" && rr.Name == domain:
|
||||
for _, r := range rr.Records {
|
||||
if strings.Contains(r.Content, "v=spf1") {
|
||||
hasSPF = true
|
||||
results["SPF"] = "OK"
|
||||
}
|
||||
}
|
||||
case rr.Type == "TXT" && rr.Name == "_dmarc."+domain:
|
||||
hasDMARC = true
|
||||
results["DMARC"] = "OK"
|
||||
case rr.Type == "TXT" && strings.HasSuffix(rr.Name, "._domainkey."+domain):
|
||||
results["DKIM"] = "OK"
|
||||
}
|
||||
}
|
||||
|
||||
if !hasMX {
|
||||
results["MX"] = "MISSING"
|
||||
}
|
||||
if !hasSPF {
|
||||
results["SPF"] = "MISSING"
|
||||
}
|
||||
if !hasDMARC {
|
||||
results["DMARC"] = "MISSING"
|
||||
}
|
||||
|
||||
allOK := true
|
||||
for _, v := range results {
|
||||
if v != "OK" {
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &types.DomainVerifyResult{
|
||||
Domain: strings.TrimSuffix(domain, "."),
|
||||
Results: results,
|
||||
AllOK: allOK,
|
||||
}, nil
|
||||
}
|
||||
648
internal/service/ldap.go
Normal file
648
internal/service/ldap.go
Normal file
@@ -0,0 +1,648 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/internal/schema"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// LDAPService handles FreeIPA user and group operations
|
||||
type LDAPService struct {
|
||||
client *client.LDAPClient
|
||||
baseDN string
|
||||
logger zerolog.Logger
|
||||
registry *schema.Registry
|
||||
}
|
||||
|
||||
// NewLDAPService creates a new LDAP service
|
||||
func NewLDAPService(ldapClient *client.LDAPClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService {
|
||||
return &LDAPService{
|
||||
client: ldapClient,
|
||||
baseDN: baseDN,
|
||||
logger: logger.With().Str("service", "ldap").Logger(),
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LDAPService) userBaseDN() string {
|
||||
return "cn=users,cn=accounts," + s.baseDN
|
||||
}
|
||||
|
||||
func (s *LDAPService) groupBaseDN() string {
|
||||
return "cn=groups,cn=accounts," + s.baseDN
|
||||
}
|
||||
|
||||
func (s *LDAPService) userDN(uid string) string {
|
||||
return fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(uid), s.userBaseDN())
|
||||
}
|
||||
|
||||
func (s *LDAPService) groupDN(cn string) string {
|
||||
return fmt.Sprintf("cn=%s,%s", ldap.EscapeFilter(cn), s.groupBaseDN())
|
||||
}
|
||||
|
||||
// coreUserAttrs are the base LDAP attributes for user listing (no gsc* attrs)
|
||||
var coreUserAttrs = []string{
|
||||
"uid", "givenName", "sn", "displayName", "mail", "telephoneNumber",
|
||||
"title", "nsAccountLock", "loginShell", "homeDirectory", "memberOf",
|
||||
}
|
||||
|
||||
// userSearchAttrs returns core attrs plus all gsc* attrs for full user retrieval
|
||||
func (s *LDAPService) userSearchAttrs() []string {
|
||||
gscAttrs := s.registry.AllUserAttrs()
|
||||
attrs := make([]string, 0, len(coreUserAttrs)+len(gscAttrs)+1)
|
||||
attrs = append(attrs, coreUserAttrs...)
|
||||
attrs = append(attrs, "objectClass")
|
||||
attrs = append(attrs, gscAttrs...)
|
||||
return attrs
|
||||
}
|
||||
|
||||
var groupAttrs = []string{
|
||||
"cn", "description", "member", "gidNumber",
|
||||
}
|
||||
|
||||
// ListUsers searches for users, optionally filtering by search string,
|
||||
// service objectClasses, and/or arbitrary LDAP attribute values.
|
||||
//
|
||||
// attrFilters maps raw LDAP attribute names to match values. Values may
|
||||
// contain LDAP wildcards (e.g. "*@example.com"). The attribute name itself
|
||||
// is sanitised to prevent filter injection.
|
||||
func (s *LDAPService) ListUsers(search string, limit int, serviceFilters []string, attrFilters map[string]string) ([]types.LDAPUser, error) {
|
||||
// Start with base object class filter
|
||||
parts := []string{"(objectClass=posixAccount)"}
|
||||
|
||||
// Free-text search across core fields
|
||||
if search != "" {
|
||||
escaped := ldap.EscapeFilter(search)
|
||||
parts = append(parts, fmt.Sprintf("(|(uid=*%s*)(givenName=*%s*)(sn=*%s*)(mail=*%s*))",
|
||||
escaped, escaped, escaped, escaped))
|
||||
}
|
||||
|
||||
// Service objectClass filters
|
||||
for _, svc := range serviceFilters {
|
||||
oc := s.registry.UserOCForDomain(svc)
|
||||
if oc != "" {
|
||||
parts = append(parts, fmt.Sprintf("(objectClass=%s)", oc))
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic LDAP attribute filters
|
||||
// Collect extra attrs we need to request so the server evaluates the filter
|
||||
var extraAttrs []string
|
||||
for attr, val := range attrFilters {
|
||||
// Sanitise attribute name: only allow alphanumeric, dash, semicolon
|
||||
safe := true
|
||||
for _, ch := range attr {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == ';') {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !safe || attr == "" {
|
||||
continue
|
||||
}
|
||||
// Escape value but preserve * wildcards for substring matching.
|
||||
// Split on *, escape each segment, rejoin with *.
|
||||
segments := strings.Split(val, "*")
|
||||
for i, seg := range segments {
|
||||
segments[i] = ldap.EscapeFilter(seg)
|
||||
}
|
||||
escapedVal := strings.Join(segments, "*")
|
||||
parts = append(parts, fmt.Sprintf("(%s=%s)", attr, escapedVal))
|
||||
extraAttrs = append(extraAttrs, attr)
|
||||
}
|
||||
|
||||
// Build final filter
|
||||
var filter string
|
||||
if len(parts) == 1 {
|
||||
filter = parts[0]
|
||||
} else {
|
||||
filter = "(&" + strings.Join(parts, "") + ")"
|
||||
}
|
||||
|
||||
// When service filters are present, fetch full gsc* attrs so the
|
||||
// response includes the services block (e.g. gscSID for chat).
|
||||
includeServices := len(serviceFilters) > 0
|
||||
var attrs []string
|
||||
if includeServices {
|
||||
attrs = s.userSearchAttrs()
|
||||
if len(extraAttrs) > 0 {
|
||||
attrs = append(attrs, extraAttrs...)
|
||||
}
|
||||
} else {
|
||||
attrs = coreUserAttrs
|
||||
if len(extraAttrs) > 0 {
|
||||
attrs = make([]string, len(coreUserAttrs), len(coreUserAttrs)+len(extraAttrs))
|
||||
copy(attrs, coreUserAttrs)
|
||||
attrs = append(attrs, extraAttrs...)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := s.client.Search(s.userBaseDN(), filter, attrs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := make([]types.LDAPUser, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
users = append(users, s.entryToUser(entry, includeServices))
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetUser gets a user by UID with full service attributes
|
||||
func (s *LDAPService) GetUser(uid string) (*types.LDAPUser, error) {
|
||||
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
|
||||
entry, err := s.client.SearchOne(s.userBaseDN(), filter, s.userSearchAttrs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
user := s.entryToUser(entry, true)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserServices returns only service attributes for a user
|
||||
func (s *LDAPService) GetUserServices(uid string, domain string) (map[string]map[string]interface{}, error) {
|
||||
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
|
||||
entry, err := s.client.SearchOne(s.userBaseDN(), filter, s.userSearchAttrs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
services := s.extractServices(entry)
|
||||
if domain != "" {
|
||||
filtered := make(map[string]map[string]interface{})
|
||||
if svc, ok := services[domain]; ok {
|
||||
filtered[domain] = svc
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new FreeIPA user
|
||||
func (s *LDAPService) CreateUser(req *types.LDAPUserCreate) (*types.LDAPUser, error) {
|
||||
dn := s.userDN(req.UID)
|
||||
|
||||
objectClasses := []string{"top", "person", "organizationalPerson", "inetOrgPerson", "posixAccount", "krbPrincipalAux", "ipaObject"}
|
||||
|
||||
addReq := ldap.NewAddRequest(dn, nil)
|
||||
addReq.Attribute("uid", []string{req.UID})
|
||||
addReq.Attribute("givenName", []string{req.FirstName})
|
||||
addReq.Attribute("sn", []string{req.LastName})
|
||||
addReq.Attribute("cn", []string{req.FirstName + " " + req.LastName})
|
||||
addReq.Attribute("displayName", []string{req.FirstName + " " + req.LastName})
|
||||
|
||||
if req.Email != "" {
|
||||
addReq.Attribute("mail", []string{req.Email})
|
||||
}
|
||||
if req.Phone != "" {
|
||||
addReq.Attribute("telephoneNumber", []string{req.Phone})
|
||||
}
|
||||
if req.Title != "" {
|
||||
addReq.Attribute("title", []string{req.Title})
|
||||
}
|
||||
|
||||
shell := "/bin/bash"
|
||||
if req.Shell != "" {
|
||||
shell = req.Shell
|
||||
}
|
||||
addReq.Attribute("loginShell", []string{shell})
|
||||
addReq.Attribute("homeDirectory", []string{"/home/" + req.UID})
|
||||
|
||||
// Process service attributes
|
||||
if len(req.Services) > 0 {
|
||||
svcOCs, svcAttrs, err := s.resolveServices(req.Services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid services: %w", err)
|
||||
}
|
||||
objectClasses = append(objectClasses, svcOCs...)
|
||||
for attrName, vals := range svcAttrs {
|
||||
addReq.Attribute(attrName, vals)
|
||||
}
|
||||
// Add audit timestamps
|
||||
now := time.Now().UTC().Format("20060102150405Z")
|
||||
addReq.Attribute("gscCreatedAt", []string{now})
|
||||
addReq.Attribute("gscModifiedAt", []string{now})
|
||||
}
|
||||
|
||||
addReq.Attribute("objectClass", objectClasses)
|
||||
|
||||
if err := s.client.Add(addReq); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Set password if provided
|
||||
if req.Password != "" {
|
||||
if err := s.client.PasswordModify(dn, req.Password); err != nil {
|
||||
s.logger.Warn().Err(err).Str("uid", req.UID).Msg("user created but password set failed")
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetUser(req.UID)
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's attributes
|
||||
func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) {
|
||||
dn := s.userDN(uid)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
modified := false
|
||||
|
||||
if req.FirstName != nil {
|
||||
modReq.Replace("givenName", []string{*req.FirstName})
|
||||
modified = true
|
||||
}
|
||||
if req.LastName != nil {
|
||||
modReq.Replace("sn", []string{*req.LastName})
|
||||
modified = true
|
||||
}
|
||||
if req.FirstName != nil || req.LastName != nil {
|
||||
// Update display name and cn
|
||||
first, last := "", ""
|
||||
if req.FirstName != nil {
|
||||
first = *req.FirstName
|
||||
}
|
||||
if req.LastName != nil {
|
||||
last = *req.LastName
|
||||
}
|
||||
if first != "" || last != "" {
|
||||
display := strings.TrimSpace(first + " " + last)
|
||||
if display != "" {
|
||||
modReq.Replace("displayName", []string{display})
|
||||
modReq.Replace("cn", []string{display})
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Email != nil {
|
||||
modReq.Replace("mail", []string{*req.Email})
|
||||
modified = true
|
||||
}
|
||||
if req.Phone != nil {
|
||||
modReq.Replace("telephoneNumber", []string{*req.Phone})
|
||||
modified = true
|
||||
}
|
||||
if req.Title != nil {
|
||||
modReq.Replace("title", []string{*req.Title})
|
||||
modified = true
|
||||
}
|
||||
if req.Shell != nil {
|
||||
modReq.Replace("loginShell", []string{*req.Shell})
|
||||
modified = true
|
||||
}
|
||||
if req.Disabled != nil {
|
||||
if *req.Disabled {
|
||||
modReq.Replace("nsAccountLock", []string{"TRUE"})
|
||||
} else {
|
||||
modReq.Replace("nsAccountLock", []string{"FALSE"})
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Process service attributes
|
||||
if len(req.Services) > 0 {
|
||||
svcOCs, svcAttrs, err := s.resolveServices(req.Services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid services: %w", err)
|
||||
}
|
||||
|
||||
// Fetch current objectClasses to determine which to add
|
||||
currentOCs, err := s.getCurrentObjectClasses(uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read current objectClasses: %w", err)
|
||||
}
|
||||
|
||||
currentOCSet := make(map[string]bool, len(currentOCs))
|
||||
for _, oc := range currentOCs {
|
||||
currentOCSet[oc] = true
|
||||
}
|
||||
|
||||
newOCs := make([]string, 0)
|
||||
for _, oc := range svcOCs {
|
||||
if !currentOCSet[oc] {
|
||||
newOCs = append(newOCs, oc)
|
||||
}
|
||||
}
|
||||
if len(newOCs) > 0 {
|
||||
modReq.Add("objectClass", newOCs)
|
||||
}
|
||||
|
||||
for attrName, vals := range svcAttrs {
|
||||
modReq.Replace(attrName, vals)
|
||||
}
|
||||
|
||||
// Update audit timestamp
|
||||
now := time.Now().UTC().Format("20060102150405Z")
|
||||
modReq.Replace("gscModifiedAt", []string{now})
|
||||
|
||||
modified = true
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return s.GetUser(uid)
|
||||
}
|
||||
|
||||
if err := s.client.Modify(modReq); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUser(uid)
|
||||
}
|
||||
|
||||
// DisableUser disables a user account
|
||||
func (s *LDAPService) DisableUser(uid string) error {
|
||||
dn := s.userDN(uid)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
modReq.Replace("nsAccountLock", []string{"TRUE"})
|
||||
return s.client.Modify(modReq)
|
||||
}
|
||||
|
||||
// ResetPassword resets a user's password
|
||||
func (s *LDAPService) ResetPassword(uid, newPassword string) error {
|
||||
dn := s.userDN(uid)
|
||||
return s.client.PasswordModify(dn, newPassword)
|
||||
}
|
||||
|
||||
// GetUserGroups lists groups a user belongs to
|
||||
func (s *LDAPService) GetUserGroups(uid string) ([]string, error) {
|
||||
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
|
||||
entry, err := s.client.SearchOne(s.userBaseDN(), filter, []string{"memberOf"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
memberOf := entry.GetAttributeValues("memberOf")
|
||||
groups := make([]string, 0, len(memberOf))
|
||||
for _, dn := range memberOf {
|
||||
// Extract cn from DN
|
||||
parts := strings.Split(dn, ",")
|
||||
if len(parts) > 0 && strings.HasPrefix(parts[0], "cn=") {
|
||||
groups = append(groups, strings.TrimPrefix(parts[0], "cn="))
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// ListGroups searches for groups
|
||||
func (s *LDAPService) ListGroups(search string, limit int) ([]types.LDAPGroup, error) {
|
||||
filter := "(objectClass=groupOfNames)"
|
||||
if search != "" {
|
||||
escaped := ldap.EscapeFilter(search)
|
||||
filter = fmt.Sprintf("(&(objectClass=groupOfNames)(|(cn=*%s*)(description=*%s*)))", escaped, escaped)
|
||||
}
|
||||
|
||||
entries, err := s.client.Search(s.groupBaseDN(), filter, groupAttrs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]types.LDAPGroup, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
groups = append(groups, s.entryToGroup(entry))
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetGroup gets a group by CN
|
||||
func (s *LDAPService) GetGroup(cn string) (*types.LDAPGroup, error) {
|
||||
filter := fmt.Sprintf("(&(objectClass=groupOfNames)(cn=%s))", ldap.EscapeFilter(cn))
|
||||
entry, err := s.client.SearchOne(s.groupBaseDN(), filter, groupAttrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
group := s.entryToGroup(entry)
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
// CreateGroup creates a new group
|
||||
func (s *LDAPService) CreateGroup(req *types.LDAPGroupCreate) (*types.LDAPGroup, error) {
|
||||
dn := s.groupDN(req.CN)
|
||||
|
||||
addReq := ldap.NewAddRequest(dn, nil)
|
||||
addReq.Attribute("objectClass", []string{"top", "groupOfNames", "posixGroup", "ipaObject"})
|
||||
addReq.Attribute("cn", []string{req.CN})
|
||||
if req.Description != "" {
|
||||
addReq.Attribute("description", []string{req.Description})
|
||||
}
|
||||
|
||||
if err := s.client.Add(addReq); err != nil {
|
||||
return nil, fmt.Errorf("failed to create group: %w", err)
|
||||
}
|
||||
|
||||
return s.GetGroup(req.CN)
|
||||
}
|
||||
|
||||
// UpdateGroup updates a group's attributes
|
||||
func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) {
|
||||
dn := s.groupDN(cn)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
|
||||
if req.Description != nil {
|
||||
modReq.Replace("description", []string{*req.Description})
|
||||
}
|
||||
|
||||
if err := s.client.Modify(modReq); err != nil {
|
||||
return nil, fmt.Errorf("failed to update group: %w", err)
|
||||
}
|
||||
|
||||
return s.GetGroup(cn)
|
||||
}
|
||||
|
||||
// DeleteGroup deletes a group
|
||||
func (s *LDAPService) DeleteGroup(cn string) error {
|
||||
dn := s.groupDN(cn)
|
||||
return s.client.Delete(dn)
|
||||
}
|
||||
|
||||
// GetGroupMembers lists members of a group
|
||||
func (s *LDAPService) GetGroupMembers(cn string) ([]string, error) {
|
||||
group, err := s.GetGroup(cn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if group == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return group.Members, nil
|
||||
}
|
||||
|
||||
// AddGroupMembers adds members to a group
|
||||
func (s *LDAPService) AddGroupMembers(cn string, uids []string) error {
|
||||
dn := s.groupDN(cn)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
|
||||
for _, uid := range uids {
|
||||
memberDN := s.userDN(uid)
|
||||
modReq.Add("member", []string{memberDN})
|
||||
}
|
||||
|
||||
return s.client.Modify(modReq)
|
||||
}
|
||||
|
||||
// RemoveGroupMember removes a member from a group
|
||||
func (s *LDAPService) RemoveGroupMember(cn, uid string) error {
|
||||
dn := s.groupDN(cn)
|
||||
memberDN := s.userDN(uid)
|
||||
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
modReq.Delete("member", []string{memberDN})
|
||||
|
||||
return s.client.Modify(modReq)
|
||||
}
|
||||
|
||||
func (s *LDAPService) entryToUser(entry *ldap.Entry, includeServices bool) types.LDAPUser {
|
||||
memberOf := entry.GetAttributeValues("memberOf")
|
||||
groups := make([]string, 0, len(memberOf))
|
||||
for _, dn := range memberOf {
|
||||
parts := strings.Split(dn, ",")
|
||||
if len(parts) > 0 && strings.HasPrefix(parts[0], "cn=") {
|
||||
groups = append(groups, strings.TrimPrefix(parts[0], "cn="))
|
||||
}
|
||||
}
|
||||
|
||||
disabled := strings.EqualFold(entry.GetAttributeValue("nsAccountLock"), "TRUE")
|
||||
|
||||
user := types.LDAPUser{
|
||||
UID: entry.GetAttributeValue("uid"),
|
||||
FirstName: entry.GetAttributeValue("givenName"),
|
||||
LastName: entry.GetAttributeValue("sn"),
|
||||
DisplayName: entry.GetAttributeValue("displayName"),
|
||||
Email: entry.GetAttributeValue("mail"),
|
||||
Phone: entry.GetAttributeValue("telephoneNumber"),
|
||||
Title: entry.GetAttributeValue("title"),
|
||||
Disabled: disabled,
|
||||
Groups: groups,
|
||||
Shell: entry.GetAttributeValue("loginShell"),
|
||||
HomeDir: entry.GetAttributeValue("homeDirectory"),
|
||||
}
|
||||
|
||||
if includeServices {
|
||||
user.ObjectClasses = entry.GetAttributeValues("objectClass")
|
||||
user.Services = s.extractServices(entry)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// extractServices extracts gsc* attributes from an LDAP entry, grouped by domain
|
||||
func (s *LDAPService) extractServices(entry *ldap.Entry) map[string]map[string]interface{} {
|
||||
services := make(map[string]map[string]interface{})
|
||||
|
||||
for _, attr := range entry.Attributes {
|
||||
def := s.registry.GetAttr(attr.Name)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
val := s.registry.LDAPValueToGo(def, attr.Values)
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if services[def.Domain] == nil {
|
||||
services[def.Domain] = make(map[string]interface{})
|
||||
}
|
||||
services[def.Domain][def.JSONName] = val
|
||||
}
|
||||
|
||||
return services
|
||||
}
|
||||
|
||||
// resolveServices validates and converts service attributes to LDAP format.
|
||||
// Returns: required objectClasses, LDAP attribute map, or error.
|
||||
func (s *LDAPService) resolveServices(services map[string]map[string]interface{}) ([]string, map[string][]string, error) {
|
||||
ldapAttrs := make(map[string][]string)
|
||||
usedLDAPNames := make([]string, 0)
|
||||
|
||||
for domain, attrs := range services {
|
||||
domainDefs := s.registry.AttrsForDomain(domain)
|
||||
if domainDefs == nil {
|
||||
return nil, nil, fmt.Errorf("unknown service domain: %s", domain)
|
||||
}
|
||||
|
||||
for jsonName, value := range attrs {
|
||||
def := s.registry.GetAttrByJSON(domain, jsonName)
|
||||
if def == nil {
|
||||
return nil, nil, fmt.Errorf("unknown attribute %s in domain %s", jsonName, domain)
|
||||
}
|
||||
if def.ReadOnly {
|
||||
continue // skip read-only attrs silently
|
||||
}
|
||||
|
||||
vals, err := s.registry.GoValueToLDAP(def, value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("attribute %s.%s: %w", domain, jsonName, err)
|
||||
}
|
||||
if vals != nil {
|
||||
ldapAttrs[def.LDAPName] = vals
|
||||
usedLDAPNames = append(usedLDAPNames, def.LDAPName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine required objectClasses
|
||||
ocs := s.registry.RequiredOCsForAttrs(usedLDAPNames)
|
||||
|
||||
// Validate that all MUST attrs for each OC are provided
|
||||
for _, ocName := range ocs {
|
||||
oc := s.registry.GetObjectClass(ocName)
|
||||
if oc == nil {
|
||||
continue
|
||||
}
|
||||
for _, must := range oc.Must {
|
||||
if _, ok := ldapAttrs[must]; !ok {
|
||||
return nil, nil, fmt.Errorf("objectClass %s requires attribute %s", ocName, must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ocs, ldapAttrs, nil
|
||||
}
|
||||
|
||||
// getCurrentObjectClasses fetches the current objectClasses of a user entry
|
||||
func (s *LDAPService) getCurrentObjectClasses(uid string) ([]string, error) {
|
||||
filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid))
|
||||
entry, err := s.client.SearchOne(s.userBaseDN(), filter, []string{"objectClass"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("user not found: %s", uid)
|
||||
}
|
||||
return entry.GetAttributeValues("objectClass"), nil
|
||||
}
|
||||
|
||||
func (s *LDAPService) entryToGroup(entry *ldap.Entry) types.LDAPGroup {
|
||||
members := entry.GetAttributeValues("member")
|
||||
uids := make([]string, 0, len(members))
|
||||
for _, dn := range members {
|
||||
parts := strings.Split(dn, ",")
|
||||
if len(parts) > 0 && strings.HasPrefix(parts[0], "uid=") {
|
||||
uids = append(uids, strings.TrimPrefix(parts[0], "uid="))
|
||||
}
|
||||
}
|
||||
|
||||
return types.LDAPGroup{
|
||||
CN: entry.GetAttributeValue("cn"),
|
||||
Description: entry.GetAttributeValue("description"),
|
||||
Members: uids,
|
||||
GIDNumber: entry.GetAttributeValue("gidNumber"),
|
||||
}
|
||||
}
|
||||
321
internal/service/ldap_entities.go
Normal file
321
internal/service/ldap_entities.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/internal/schema"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// LDAPEntityService handles generic LDAP entity CRUD operations
|
||||
type LDAPEntityService struct {
|
||||
client *client.LDAPClient
|
||||
baseDN string
|
||||
registry *schema.Registry
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewLDAPEntityService creates a new entity service
|
||||
func NewLDAPEntityService(ldapClient *client.LDAPClient, baseDN string, registry *schema.Registry, logger zerolog.Logger) *LDAPEntityService {
|
||||
return &LDAPEntityService{
|
||||
client: ldapClient,
|
||||
baseDN: baseDN,
|
||||
registry: registry,
|
||||
logger: logger.With().Str("service", "ldap-entities").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// entityBaseDN returns the full base DN for an entity type
|
||||
func (s *LDAPEntityService) entityBaseDN(et *schema.EntityTypeDef) string {
|
||||
return et.BaseDN + "," + s.baseDN
|
||||
}
|
||||
|
||||
// entityDN returns the full DN for a specific entity
|
||||
func (s *LDAPEntityService) entityDN(et *schema.EntityTypeDef, rdnValue string) string {
|
||||
return fmt.Sprintf("%s=%s,%s", et.RDNAttribute, ldap.EscapeFilter(rdnValue), s.entityBaseDN(et))
|
||||
}
|
||||
|
||||
// entityAttrs returns all searchable LDAP attribute names for an entity type
|
||||
func (s *LDAPEntityService) entityAttrs(et *schema.EntityTypeDef) []string {
|
||||
attrs := []string{"objectClass"}
|
||||
for _, ocName := range et.ObjectClasses {
|
||||
oc := s.registry.GetObjectClass(ocName)
|
||||
if oc == nil {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, oc.Must...)
|
||||
attrs = append(attrs, oc.May...)
|
||||
}
|
||||
// Deduplicate
|
||||
seen := make(map[string]bool, len(attrs))
|
||||
unique := make([]string, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
if !seen[a] {
|
||||
seen[a] = true
|
||||
unique = append(unique, a)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// ListEntities searches for entities of a given type
|
||||
func (s *LDAPEntityService) ListEntities(typeName, search string, limit int) ([]types.LDAPEntity, error) {
|
||||
et := s.registry.GetEntityType(typeName)
|
||||
if et == nil {
|
||||
return nil, fmt.Errorf("unknown entity type: %s", typeName)
|
||||
}
|
||||
|
||||
filter := et.SearchFilter
|
||||
if search != "" {
|
||||
escaped := ldap.EscapeFilter(search)
|
||||
// Search by RDN attribute or description
|
||||
filter = fmt.Sprintf("(&%s(|(%s=*%s*)(gscDescription=*%s*)))",
|
||||
et.SearchFilter, et.RDNAttribute, escaped, escaped)
|
||||
}
|
||||
|
||||
attrs := s.entityAttrs(et)
|
||||
entries, err := s.client.Search(s.entityBaseDN(et), filter, attrs, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entity search failed: %w", err)
|
||||
}
|
||||
|
||||
entities := make([]types.LDAPEntity, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entities = append(entities, s.entryToEntity(entry, et))
|
||||
}
|
||||
return entities, nil
|
||||
}
|
||||
|
||||
// GetEntity retrieves a single entity by its RDN value
|
||||
func (s *LDAPEntityService) GetEntity(typeName, rdnValue string) (*types.LDAPEntity, error) {
|
||||
et := s.registry.GetEntityType(typeName)
|
||||
if et == nil {
|
||||
return nil, fmt.Errorf("unknown entity type: %s", typeName)
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))",
|
||||
et.SearchFilter, et.RDNAttribute, ldap.EscapeFilter(rdnValue))
|
||||
attrs := s.entityAttrs(et)
|
||||
|
||||
entry, err := s.client.SearchOne(s.entityBaseDN(et), filter, attrs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entity lookup failed: %w", err)
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entity := s.entryToEntity(entry, et)
|
||||
return &entity, nil
|
||||
}
|
||||
|
||||
// CreateEntity creates a new entity
|
||||
func (s *LDAPEntityService) CreateEntity(typeName string, req *types.LDAPEntityCreate) (*types.LDAPEntity, error) {
|
||||
et := s.registry.GetEntityType(typeName)
|
||||
if et == nil {
|
||||
return nil, fmt.Errorf("unknown entity type: %s", typeName)
|
||||
}
|
||||
|
||||
// Resolve attributes from JSON names to LDAP names
|
||||
ldapAttrs, err := s.resolveEntityAttrs(et, req.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate required attributes
|
||||
for _, reqAttr := range et.RequiredAttrs {
|
||||
if _, ok := ldapAttrs[reqAttr]; !ok {
|
||||
// Try to find JSON name for better error message
|
||||
def := s.registry.GetAttr(reqAttr)
|
||||
jsonName := reqAttr
|
||||
if def != nil {
|
||||
jsonName = def.JSONName
|
||||
}
|
||||
return nil, fmt.Errorf("required attribute missing: %s", jsonName)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine RDN value
|
||||
rdnVals, ok := ldapAttrs[et.RDNAttribute]
|
||||
if !ok || len(rdnVals) == 0 {
|
||||
return nil, fmt.Errorf("RDN attribute %s is required", et.RDNAttribute)
|
||||
}
|
||||
rdnValue := rdnVals[0]
|
||||
|
||||
// Build DN
|
||||
dn := s.entityDN(et, rdnValue)
|
||||
|
||||
// Add audit timestamps
|
||||
now := time.Now().UTC().Format("20060102150405Z")
|
||||
ldapAttrs["gscCreatedAt"] = []string{now}
|
||||
ldapAttrs["gscModifiedAt"] = []string{now}
|
||||
|
||||
// Create LDAP entry
|
||||
addReq := ldap.NewAddRequest(dn, nil)
|
||||
addReq.Attribute("objectClass", et.ObjectClasses)
|
||||
|
||||
for attrName, vals := range ldapAttrs {
|
||||
addReq.Attribute(attrName, vals)
|
||||
}
|
||||
|
||||
if err := s.client.Add(addReq); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
|
||||
return nil, fmt.Errorf("CONFLICT: entity already exists: %s=%s", et.RDNAttribute, rdnValue)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create entity: %w", err)
|
||||
}
|
||||
|
||||
return s.GetEntity(typeName, rdnValue)
|
||||
}
|
||||
|
||||
// UpdateEntity modifies an existing entity
|
||||
func (s *LDAPEntityService) UpdateEntity(typeName, rdnValue string, req *types.LDAPEntityUpdate) (*types.LDAPEntity, error) {
|
||||
et := s.registry.GetEntityType(typeName)
|
||||
if et == nil {
|
||||
return nil, fmt.Errorf("unknown entity type: %s", typeName)
|
||||
}
|
||||
|
||||
// Resolve attributes
|
||||
ldapAttrs, err := s.resolveEntityAttrs(et, req.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ldapAttrs) == 0 {
|
||||
return s.GetEntity(typeName, rdnValue)
|
||||
}
|
||||
|
||||
dn := s.entityDN(et, rdnValue)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
|
||||
for attrName, vals := range ldapAttrs {
|
||||
modReq.Replace(attrName, vals)
|
||||
}
|
||||
|
||||
// Update audit timestamp
|
||||
now := time.Now().UTC().Format("20060102150405Z")
|
||||
modReq.Replace("gscModifiedAt", []string{now})
|
||||
|
||||
if err := s.client.Modify(modReq); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return nil, fmt.Errorf("NOT_FOUND: entity not found: %s=%s", et.RDNAttribute, rdnValue)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to update entity: %w", err)
|
||||
}
|
||||
|
||||
return s.GetEntity(typeName, rdnValue)
|
||||
}
|
||||
|
||||
// DeleteEntity removes an entity
|
||||
func (s *LDAPEntityService) DeleteEntity(typeName, rdnValue string) error {
|
||||
et := s.registry.GetEntityType(typeName)
|
||||
if et == nil {
|
||||
return fmt.Errorf("unknown entity type: %s", typeName)
|
||||
}
|
||||
|
||||
dn := s.entityDN(et, rdnValue)
|
||||
if err := s.client.Delete(dn); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return fmt.Errorf("NOT_FOUND: entity not found: %s=%s", et.RDNAttribute, rdnValue)
|
||||
}
|
||||
return fmt.Errorf("failed to delete entity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveEntityAttrs converts JSON attribute names to LDAP attribute names with type conversion
|
||||
func (s *LDAPEntityService) resolveEntityAttrs(et *schema.EntityTypeDef, attrs map[string]interface{}) (map[string][]string, error) {
|
||||
ldapAttrs := make(map[string][]string)
|
||||
|
||||
for jsonName, value := range attrs {
|
||||
// Try to find by JSON name in the entity's domain
|
||||
def := s.registry.GetAttrByJSON(et.Domain, jsonName)
|
||||
if def == nil {
|
||||
// Also try common domain
|
||||
def = s.registry.GetAttrByJSON("common", jsonName)
|
||||
}
|
||||
if def == nil {
|
||||
// Try as direct LDAP name
|
||||
def = s.registry.GetAttr(jsonName)
|
||||
}
|
||||
if def == nil {
|
||||
return nil, fmt.Errorf("unknown attribute: %s", jsonName)
|
||||
}
|
||||
if def.ReadOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
vals, err := s.registry.GoValueToLDAP(def, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attribute %s: %w", jsonName, err)
|
||||
}
|
||||
if vals != nil {
|
||||
ldapAttrs[def.LDAPName] = vals
|
||||
}
|
||||
}
|
||||
|
||||
return ldapAttrs, nil
|
||||
}
|
||||
|
||||
// entryToEntity converts an LDAP entry to a generic entity response
|
||||
func (s *LDAPEntityService) entryToEntity(entry *ldap.Entry, et *schema.EntityTypeDef) types.LDAPEntity {
|
||||
attrs := make(map[string]interface{})
|
||||
|
||||
for _, ldapAttr := range entry.Attributes {
|
||||
if ldapAttr.Name == "objectClass" {
|
||||
continue
|
||||
}
|
||||
def := s.registry.GetAttr(ldapAttr.Name)
|
||||
if def == nil {
|
||||
// Include unregistered attrs as raw strings
|
||||
if len(ldapAttr.Values) == 1 {
|
||||
attrs[ldapAttr.Name] = ldapAttr.Values[0]
|
||||
} else if len(ldapAttr.Values) > 1 {
|
||||
attrs[ldapAttr.Name] = ldapAttr.Values
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
val := s.registry.LDAPValueToGo(def, ldapAttr.Values)
|
||||
if val != nil {
|
||||
attrs[def.JSONName] = val
|
||||
}
|
||||
}
|
||||
|
||||
// Extract RDN value
|
||||
rdnValue := ""
|
||||
if et.RDNAttribute == "cn" {
|
||||
rdnValue = entry.GetAttributeValue("cn")
|
||||
} else {
|
||||
rdnValue = entry.GetAttributeValue(et.RDNAttribute)
|
||||
}
|
||||
|
||||
return types.LDAPEntity{
|
||||
DN: entry.DN,
|
||||
Type: et.Name,
|
||||
RDN: rdnValue,
|
||||
ObjectClasses: entry.GetAttributeValues("objectClass"),
|
||||
Attributes: attrs,
|
||||
}
|
||||
}
|
||||
|
||||
// ClassifyError classifies LDAP errors for HTTP status mapping
|
||||
func ClassifyError(err error) (string, string) {
|
||||
msg := err.Error()
|
||||
if strings.HasPrefix(msg, "CONFLICT:") {
|
||||
return "conflict", strings.TrimPrefix(msg, "CONFLICT: ")
|
||||
}
|
||||
if strings.HasPrefix(msg, "NOT_FOUND:") {
|
||||
return "not_found", strings.TrimPrefix(msg, "NOT_FOUND: ")
|
||||
}
|
||||
if strings.Contains(msg, "unknown entity type") || strings.Contains(msg, "unknown attribute") || strings.Contains(msg, "required attribute") {
|
||||
return "validation", msg
|
||||
}
|
||||
return "internal", msg
|
||||
}
|
||||
1091
internal/service/pbx.go
Normal file
1091
internal/service/pbx.go
Normal file
File diff suppressed because it is too large
Load Diff
514
internal/service/persona.go
Normal file
514
internal/service/persona.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PersonaService handles persona operations against gsc_persona database
|
||||
type PersonaService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewPersonaService creates a new persona service
|
||||
func NewPersonaService(pool *pgxpool.Pool, logger zerolog.Logger) *PersonaService {
|
||||
return &PersonaService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "persona").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListPersonas lists personas for a tenant with optional status filter
|
||||
func (s *PersonaService) ListPersonas(ctx context.Context, tenantID uuid.UUID, params types.ListParams) ([]types.PersonaSummary, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM persona.personas WHERE tenant_id = $1`
|
||||
listQuery := `SELECT id, tenant_id, name, archetype, status, is_default, created_at, updated_at
|
||||
FROM persona.personas WHERE tenant_id = $1`
|
||||
|
||||
args := []interface{}{tenantID}
|
||||
argIdx := 2
|
||||
|
||||
if params.Status != "" {
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
listQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, params.Status)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
personas := make([]types.PersonaSummary, 0)
|
||||
for rows.Next() {
|
||||
var p types.PersonaSummary
|
||||
if err := rows.Scan(&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.Status, &p.IsDefault, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
personas = append(personas, p)
|
||||
}
|
||||
|
||||
return personas, total, nil
|
||||
}
|
||||
|
||||
// GetPersona gets a full persona configuration by ID and tenant
|
||||
func (s *PersonaService) GetPersona(ctx context.Context, id, tenantID uuid.UUID) (*types.PersonaConfig, error) {
|
||||
var p types.PersonaConfig
|
||||
var positiveRules, negativeRules, guardrailsConfig []byte
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, name, archetype, voice_tone, mbti,
|
||||
openness, conscientiousness, extraversion, agreeableness, neuroticism,
|
||||
positive_rules, negative_rules, backstory, world_building,
|
||||
guardrails_config, topical_rails, status,
|
||||
default_model, temperature, max_tokens_per_turn,
|
||||
moral_care, moral_fairness, moral_rights,
|
||||
moral_loyalty, moral_authority, moral_sanctity,
|
||||
is_default, created_at, updated_at
|
||||
FROM persona.personas
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, id, tenantID).Scan(
|
||||
&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI,
|
||||
&p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism,
|
||||
&positiveRules, &negativeRules, &p.Backstory, &p.WorldBuilding,
|
||||
&guardrailsConfig, &p.TopicalRails, &p.Status,
|
||||
&p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn,
|
||||
&p.MoralCare, &p.MoralFairness, &p.MoralRights,
|
||||
&p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity,
|
||||
&p.IsDefault, &p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not found: %w", err)
|
||||
}
|
||||
|
||||
p.PositiveRules = json.RawMessage(positiveRules)
|
||||
p.NegativeRules = json.RawMessage(negativeRules)
|
||||
p.GuardrailsConfig = json.RawMessage(guardrailsConfig)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// CreatePersona creates a new persona
|
||||
func (s *PersonaService) CreatePersona(ctx context.Context, req *types.PersonaCreate) (*types.PersonaConfig, error) {
|
||||
positiveRules := req.PositiveRules
|
||||
if len(positiveRules) == 0 {
|
||||
positiveRules = json.RawMessage(`[]`)
|
||||
}
|
||||
negativeRules := req.NegativeRules
|
||||
if len(negativeRules) == 0 {
|
||||
negativeRules = json.RawMessage(`[]`)
|
||||
}
|
||||
guardrailsConfig := req.GuardrailsConfig
|
||||
if len(guardrailsConfig) == 0 {
|
||||
guardrailsConfig = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
var p types.PersonaConfig
|
||||
var prOut, nrOut, gcOut []byte
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO persona.personas (
|
||||
tenant_id, name, archetype, voice_tone, mbti,
|
||||
openness, conscientiousness, extraversion, agreeableness, neuroticism,
|
||||
positive_rules, negative_rules, backstory, world_building,
|
||||
guardrails_config, topical_rails,
|
||||
default_model, temperature, max_tokens_per_turn,
|
||||
moral_care, moral_fairness, moral_rights,
|
||||
moral_loyalty, moral_authority, moral_sanctity
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
|
||||
RETURNING id, tenant_id, name, archetype, voice_tone, mbti,
|
||||
openness, conscientiousness, extraversion, agreeableness, neuroticism,
|
||||
positive_rules, negative_rules, backstory, world_building,
|
||||
guardrails_config, topical_rails, status,
|
||||
default_model, temperature, max_tokens_per_turn,
|
||||
moral_care, moral_fairness, moral_rights,
|
||||
moral_loyalty, moral_authority, moral_sanctity,
|
||||
is_default, created_at, updated_at`,
|
||||
req.TenantID, req.Name, req.Archetype, req.VoiceTone, req.MBTI,
|
||||
req.Openness, req.Conscientiousness, req.Extraversion, req.Agreeableness, req.Neuroticism,
|
||||
positiveRules, negativeRules, req.Backstory, req.WorldBuilding,
|
||||
guardrailsConfig, req.TopicalRails,
|
||||
req.DefaultModel, req.Temperature, req.MaxTokensPerTurn,
|
||||
req.MoralCare, req.MoralFairness, req.MoralRights,
|
||||
req.MoralLoyalty, req.MoralAuthority, req.MoralSanctity,
|
||||
).Scan(
|
||||
&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI,
|
||||
&p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism,
|
||||
&prOut, &nrOut, &p.Backstory, &p.WorldBuilding,
|
||||
&gcOut, &p.TopicalRails, &p.Status,
|
||||
&p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn,
|
||||
&p.MoralCare, &p.MoralFairness, &p.MoralRights,
|
||||
&p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity,
|
||||
&p.IsDefault, &p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
p.PositiveRules = json.RawMessage(prOut)
|
||||
p.NegativeRules = json.RawMessage(nrOut)
|
||||
p.GuardrailsConfig = json.RawMessage(gcOut)
|
||||
s.logger.Info().Str("id", p.ID.String()).Str("name", p.Name).Msg("Created persona")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpdatePersona updates an existing persona
|
||||
func (s *PersonaService) UpdatePersona(ctx context.Context, id, tenantID uuid.UUID, req *types.PersonaUpdate) (*types.PersonaConfig, error) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
addField := func(clause string, val interface{}) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx))
|
||||
args = append(args, val)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addField("name", *req.Name)
|
||||
}
|
||||
if req.Archetype != nil {
|
||||
addField("archetype", *req.Archetype)
|
||||
}
|
||||
if req.VoiceTone != nil {
|
||||
addField("voice_tone", *req.VoiceTone)
|
||||
}
|
||||
if req.MBTI != nil {
|
||||
addField("mbti", *req.MBTI)
|
||||
}
|
||||
if req.Openness != nil {
|
||||
addField("openness", *req.Openness)
|
||||
}
|
||||
if req.Conscientiousness != nil {
|
||||
addField("conscientiousness", *req.Conscientiousness)
|
||||
}
|
||||
if req.Extraversion != nil {
|
||||
addField("extraversion", *req.Extraversion)
|
||||
}
|
||||
if req.Agreeableness != nil {
|
||||
addField("agreeableness", *req.Agreeableness)
|
||||
}
|
||||
if req.Neuroticism != nil {
|
||||
addField("neuroticism", *req.Neuroticism)
|
||||
}
|
||||
if len(req.PositiveRules) > 0 {
|
||||
addField("positive_rules", req.PositiveRules)
|
||||
}
|
||||
if len(req.NegativeRules) > 0 {
|
||||
addField("negative_rules", req.NegativeRules)
|
||||
}
|
||||
if req.Backstory != nil {
|
||||
addField("backstory", *req.Backstory)
|
||||
}
|
||||
if req.WorldBuilding != nil {
|
||||
addField("world_building", *req.WorldBuilding)
|
||||
}
|
||||
if len(req.GuardrailsConfig) > 0 {
|
||||
addField("guardrails_config", req.GuardrailsConfig)
|
||||
}
|
||||
if req.TopicalRails != nil {
|
||||
addField("topical_rails", *req.TopicalRails)
|
||||
}
|
||||
if req.Status != nil {
|
||||
addField("status", *req.Status)
|
||||
}
|
||||
if req.DefaultModel != nil {
|
||||
addField("default_model", *req.DefaultModel)
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
addField("temperature", *req.Temperature)
|
||||
}
|
||||
if req.MaxTokensPerTurn != nil {
|
||||
addField("max_tokens_per_turn", *req.MaxTokensPerTurn)
|
||||
}
|
||||
if req.MoralCare != nil {
|
||||
addField("moral_care", *req.MoralCare)
|
||||
}
|
||||
if req.MoralFairness != nil {
|
||||
addField("moral_fairness", *req.MoralFairness)
|
||||
}
|
||||
if req.MoralRights != nil {
|
||||
addField("moral_rights", *req.MoralRights)
|
||||
}
|
||||
if req.MoralLoyalty != nil {
|
||||
addField("moral_loyalty", *req.MoralLoyalty)
|
||||
}
|
||||
if req.MoralAuthority != nil {
|
||||
addField("moral_authority", *req.MoralAuthority)
|
||||
}
|
||||
if req.MoralSanctity != nil {
|
||||
addField("moral_sanctity", *req.MoralSanctity)
|
||||
}
|
||||
if req.IsDefault != nil {
|
||||
addField("is_default", *req.IsDefault)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetPersona(ctx, id, tenantID)
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
query := fmt.Sprintf("UPDATE persona.personas SET %s WHERE id = $%d AND tenant_id = $%d",
|
||||
joinClauses(setClauses), argIdx, argIdx+1)
|
||||
args = append(args, id, tenantID)
|
||||
query += ` RETURNING id, tenant_id, name, archetype, voice_tone, mbti,
|
||||
openness, conscientiousness, extraversion, agreeableness, neuroticism,
|
||||
positive_rules, negative_rules, backstory, world_building,
|
||||
guardrails_config, topical_rails, status,
|
||||
default_model, temperature, max_tokens_per_turn,
|
||||
moral_care, moral_fairness, moral_rights,
|
||||
moral_loyalty, moral_authority, moral_sanctity,
|
||||
is_default, created_at, updated_at`
|
||||
|
||||
var p types.PersonaConfig
|
||||
var positiveRules, negativeRules, guardrailsConfig []byte
|
||||
err := s.pool.QueryRow(ctx, query, args...).Scan(
|
||||
&p.ID, &p.TenantID, &p.Name, &p.Archetype, &p.VoiceTone, &p.MBTI,
|
||||
&p.Openness, &p.Conscientiousness, &p.Extraversion, &p.Agreeableness, &p.Neuroticism,
|
||||
&positiveRules, &negativeRules, &p.Backstory, &p.WorldBuilding,
|
||||
&guardrailsConfig, &p.TopicalRails, &p.Status,
|
||||
&p.DefaultModel, &p.Temperature, &p.MaxTokensPerTurn,
|
||||
&p.MoralCare, &p.MoralFairness, &p.MoralRights,
|
||||
&p.MoralLoyalty, &p.MoralAuthority, &p.MoralSanctity,
|
||||
&p.IsDefault, &p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
p.PositiveRules = json.RawMessage(positiveRules)
|
||||
p.NegativeRules = json.RawMessage(negativeRules)
|
||||
p.GuardrailsConfig = json.RawMessage(guardrailsConfig)
|
||||
s.logger.Info().Str("id", id.String()).Msg("Updated persona")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// DeletePersona deletes a persona
|
||||
func (s *PersonaService) DeletePersona(ctx context.Context, id, tenantID uuid.UUID) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM persona.personas WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("persona not found")
|
||||
}
|
||||
s.logger.Info().Str("id", id.String()).Msg("Deleted persona")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSelfModel returns the self-model snapshot for a persona
|
||||
func (s *PersonaService) GetSelfModel(ctx context.Context, personaID, tenantID uuid.UUID) (*types.SelfModelSnapshot, error) {
|
||||
snapshot := &types.SelfModelSnapshot{
|
||||
IdentityConstraints: make([]types.IdentityConstraint, 0),
|
||||
Commitments: make([]types.PersonaCommitment, 0),
|
||||
ConscienceStandards: make([]types.ConscienceStandard, 0),
|
||||
}
|
||||
|
||||
// Identity constraints
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT constraint_type, constraint_text, description, source, strength
|
||||
FROM persona.identity_constraints
|
||||
WHERE persona_id = $1 AND tenant_id = $2 AND is_active = true
|
||||
ORDER BY strength DESC
|
||||
`, personaID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("constraints query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var c types.IdentityConstraint
|
||||
var strength *float64
|
||||
if err := rows.Scan(&c.ConstraintType, &c.ConstraintText, &c.Description, &c.Source, &strength); err != nil {
|
||||
return nil, fmt.Errorf("constraint scan failed: %w", err)
|
||||
}
|
||||
if strength != nil {
|
||||
c.Strength = *strength
|
||||
} else {
|
||||
c.Strength = 1.0
|
||||
}
|
||||
snapshot.IdentityConstraints = append(snapshot.IdentityConstraints, c)
|
||||
}
|
||||
|
||||
// Commitments
|
||||
//
|
||||
// persona_commitments tracks session-bound commitments the assistant
|
||||
// has made during conversation; it has no `source` or `strength`
|
||||
// columns (the active flag is `status='active'`, not `is_active`).
|
||||
// Synthesise both fields for the snapshot so the SelfModel contract
|
||||
// stays stable for callers.
|
||||
commitRows, err := s.pool.Query(ctx, `
|
||||
SELECT commitment_text, COALESCE(commitment_type, '')
|
||||
FROM persona.persona_commitments
|
||||
WHERE persona_id = $1 AND tenant_id = $2 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
`, personaID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commitments query failed: %w", err)
|
||||
}
|
||||
defer commitRows.Close()
|
||||
|
||||
commitSource := "learned"
|
||||
for commitRows.Next() {
|
||||
var c types.PersonaCommitment
|
||||
if err := commitRows.Scan(&c.CommitmentText, &c.CommitmentType); err != nil {
|
||||
return nil, fmt.Errorf("commitment scan failed: %w", err)
|
||||
}
|
||||
c.Source = &commitSource
|
||||
c.Strength = 1.0
|
||||
snapshot.Commitments = append(snapshot.Commitments, c)
|
||||
}
|
||||
|
||||
// Conscience standards
|
||||
stdRows, err := s.pool.Query(ctx, `
|
||||
SELECT standard_text, standard_type, moral_foundation, strength
|
||||
FROM persona.conscience_standards
|
||||
WHERE persona_id = $1 AND tenant_id = $2 AND is_active = true
|
||||
ORDER BY strength DESC
|
||||
`, personaID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("standards query failed: %w", err)
|
||||
}
|
||||
defer stdRows.Close()
|
||||
|
||||
for stdRows.Next() {
|
||||
var s types.ConscienceStandard
|
||||
var strength *float64
|
||||
if err := stdRows.Scan(&s.StandardText, &s.StandardType, &s.MoralFoundation, &strength); err != nil {
|
||||
return nil, fmt.Errorf("standard scan failed: %w", err)
|
||||
}
|
||||
if strength != nil {
|
||||
s.Strength = *strength
|
||||
} else {
|
||||
s.Strength = 1.0
|
||||
}
|
||||
snapshot.ConscienceStandards = append(snapshot.ConscienceStandards, s)
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// SearchExperiences returns experiences for a persona ordered by importance
|
||||
func (s *PersonaService) SearchExperiences(ctx context.Context, personaID, tenantID uuid.UUID, limit int) ([]types.Experience, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, event_summary, event_type, occurred_at, place,
|
||||
actors, outcome, outcome_detail,
|
||||
emotional_valence, lesson_learned, importance_score
|
||||
FROM persona.experiences
|
||||
WHERE persona_id = $1 AND tenant_id = $2
|
||||
ORDER BY importance_score DESC, occurred_at DESC
|
||||
LIMIT $3
|
||||
`, personaID, tenantID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("experiences query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
experiences := make([]types.Experience, 0)
|
||||
for rows.Next() {
|
||||
var e types.Experience
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.EventSummary, &e.EventType, &e.OccurredAt, &e.Place,
|
||||
&e.Actors, &e.Outcome, &e.OutcomeDetail,
|
||||
&e.EmotionalValence, &e.LessonLearned, &e.ImportanceScore,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("experience scan failed: %w", err)
|
||||
}
|
||||
experiences = append(experiences, e)
|
||||
}
|
||||
|
||||
return experiences, nil
|
||||
}
|
||||
|
||||
// GetEvaluations returns evaluations for a session
|
||||
func (s *PersonaService) GetEvaluations(ctx context.Context, sessionID uuid.UUID, limit int) ([]types.Evaluation, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.role_fidelity, e.voice_consistency,
|
||||
e.safety_compliance, e.character_break,
|
||||
e.drift_score, e.evaluator_model, e.evaluated_at
|
||||
FROM persona.evaluations e
|
||||
JOIN persona.messages m ON m.id = e.message_id
|
||||
WHERE m.session_id = $1
|
||||
ORDER BY e.evaluated_at DESC
|
||||
LIMIT $2
|
||||
`, sessionID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluations query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
evaluations := make([]types.Evaluation, 0)
|
||||
for rows.Next() {
|
||||
var e types.Evaluation
|
||||
if err := rows.Scan(
|
||||
&e.RoleFidelity, &e.VoiceConsistency,
|
||||
&e.SafetyCompliance, &e.CharacterBreak,
|
||||
&e.DriftScore, &e.EvaluatorModel, &e.EvaluatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("evaluation scan failed: %w", err)
|
||||
}
|
||||
evaluations = append(evaluations, e)
|
||||
}
|
||||
|
||||
return evaluations, nil
|
||||
}
|
||||
|
||||
// GetMoralPattern returns moral assessments for a session
|
||||
func (s *PersonaService) GetMoralPattern(ctx context.Context, sessionID, tenantID uuid.UUID) ([]types.MoralAssessment, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT activated_foundations, assessment_text,
|
||||
has_tension, tension_foundations,
|
||||
resolution_foundation, confidence
|
||||
FROM persona.moral_assessments
|
||||
WHERE session_id = $1 AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
`, sessionID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("moral pattern query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
assessments := make([]types.MoralAssessment, 0)
|
||||
for rows.Next() {
|
||||
var a types.MoralAssessment
|
||||
var activatedFoundations []byte
|
||||
if err := rows.Scan(
|
||||
&activatedFoundations, &a.AssessmentText,
|
||||
&a.HasTension, &a.TensionFoundations,
|
||||
&a.ResolutionFoundation, &a.Confidence,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("moral assessment scan failed: %w", err)
|
||||
}
|
||||
a.ActivatedFoundations = json.RawMessage(activatedFoundations)
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
|
||||
return assessments, nil
|
||||
}
|
||||
78
internal/service/personal_agent.go
Normal file
78
internal/service/personal_agent.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PersonalAgentService handles personal agent config operations
|
||||
type PersonalAgentService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewPersonalAgentService creates a new personal agent service
|
||||
func NewPersonalAgentService(pool *pgxpool.Pool, logger zerolog.Logger) *PersonalAgentService {
|
||||
return &PersonalAgentService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "personal_agent").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig gets a user's personal agent config
|
||||
func (s *PersonalAgentService) GetConfig(ctx context.Context, userID, tenantID uuid.UUID) (*types.UserAgentConfig, error) {
|
||||
var c types.UserAgentConfig
|
||||
var configBytes []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, user_id, tenant_id, config, created_at, updated_at
|
||||
FROM admin.user_agent_configs WHERE user_id = $1 AND tenant_id = $2`,
|
||||
userID, tenantID).
|
||||
Scan(&c.ID, &c.UserID, &c.TenantID, &configBytes, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not found: %w", err)
|
||||
}
|
||||
c.Config = json.RawMessage(configBytes)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpsertConfig creates or updates a user's personal agent config
|
||||
func (s *PersonalAgentService) UpsertConfig(ctx context.Context, req *types.UserAgentConfigUpsert) (*types.UserAgentConfig, error) {
|
||||
var c types.UserAgentConfig
|
||||
var configBytes []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO admin.user_agent_configs (user_id, tenant_id, config)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, tenant_id) DO UPDATE
|
||||
SET config = EXCLUDED.config, updated_at = NOW()
|
||||
RETURNING id, user_id, tenant_id, config, created_at, updated_at`,
|
||||
req.UserID, req.TenantID, req.Config).
|
||||
Scan(&c.ID, &c.UserID, &c.TenantID, &configBytes, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert failed: %w", err)
|
||||
}
|
||||
c.Config = json.RawMessage(configBytes)
|
||||
s.logger.Info().Str("userId", req.UserID.String()).Str("tenantId", req.TenantID.String()).Msg("Upserted personal agent config")
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DeleteConfig deletes a user's personal agent config
|
||||
func (s *PersonalAgentService) DeleteConfig(ctx context.Context, userID, tenantID uuid.UUID) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM admin.user_agent_configs WHERE user_id = $1 AND tenant_id = $2`,
|
||||
userID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("config not found")
|
||||
}
|
||||
s.logger.Info().Str("userId", userID.String()).Str("tenantId", tenantID.String()).Msg("Deleted personal agent config")
|
||||
return nil
|
||||
}
|
||||
110
internal/service/pgp.go
Normal file
110
internal/service/pgp.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// PGPService handles Hockeypuck PGP key operations
|
||||
type PGPService struct {
|
||||
client *client.HockeypuckClient
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewPGPService creates a new PGP service
|
||||
func NewPGPService(hkpClient *client.HockeypuckClient, logger zerolog.Logger) *PGPService {
|
||||
return &PGPService{
|
||||
client: hkpClient,
|
||||
logger: logger.With().Str("service", "pgp").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchKeys searches for PGP keys
|
||||
func (s *PGPService) SearchKeys(query string) ([]types.PGPKey, error) {
|
||||
result, err := s.client.SearchKeys(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == "" {
|
||||
return []types.PGPKey{}, nil
|
||||
}
|
||||
|
||||
return parseMachineReadableIndex(result), nil
|
||||
}
|
||||
|
||||
// GetKey retrieves a PGP key by key ID
|
||||
func (s *PGPService) GetKey(keyID string) (*types.PGPKey, error) {
|
||||
armoredKey, err := s.client.GetKey(keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if armoredKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &types.PGPKey{
|
||||
KeyID: keyID,
|
||||
ArmoredKey: armoredKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadKey uploads a PGP public key
|
||||
func (s *PGPService) UploadKey(keyText string) error {
|
||||
return s.client.UploadKey(keyText)
|
||||
}
|
||||
|
||||
// DeleteKey deletes a PGP key
|
||||
func (s *PGPService) DeleteKey(keyID string) error {
|
||||
return s.client.DeleteKey(keyID)
|
||||
}
|
||||
|
||||
// parseMachineReadableIndex parses the HKP machine-readable index format
|
||||
func parseMachineReadableIndex(data string) []types.PGPKey {
|
||||
keys := []types.PGPKey{}
|
||||
var current *types.PGPKey
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Split(line, ":")
|
||||
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch fields[0] {
|
||||
case "pub":
|
||||
if current != nil {
|
||||
keys = append(keys, *current)
|
||||
}
|
||||
current = &types.PGPKey{}
|
||||
if len(fields) > 1 {
|
||||
current.KeyID = fields[1]
|
||||
}
|
||||
if len(fields) > 2 {
|
||||
current.Algorithm = fields[2]
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
current.Created = fields[4]
|
||||
}
|
||||
if len(fields) > 5 {
|
||||
current.Expires = fields[5]
|
||||
}
|
||||
case "uid":
|
||||
if current != nil && len(fields) > 1 {
|
||||
current.UIDs = append(current.UIDs, fields[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current != nil {
|
||||
keys = append(keys, *current)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
453
internal/service/voice_agent.go
Normal file
453
internal/service/voice_agent.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// VoiceAgentService handles voice agent config and session operations
|
||||
type VoiceAgentService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewVoiceAgentService creates a new voice agent service
|
||||
func NewVoiceAgentService(pool *pgxpool.Pool, logger zerolog.Logger) *VoiceAgentService {
|
||||
return &VoiceAgentService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "voice_agent").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice Agent Configs
|
||||
// ============================================================================
|
||||
|
||||
// ListConfigs lists voice agent configs with optional filters
|
||||
func (s *VoiceAgentService) ListConfigs(ctx context.Context, params types.ListParams, tenantID *uuid.UUID) ([]types.VoiceAgentConfig, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM voice_agent_configs WHERE 1=1`
|
||||
listQuery := `SELECT id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at
|
||||
FROM voice_agent_configs WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if tenantID != nil {
|
||||
countQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx)
|
||||
listQuery += fmt.Sprintf(" AND tenant_id = $%d", argIdx)
|
||||
args = append(args, *tenantID)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if params.Search != "" {
|
||||
countQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
listQuery += fmt.Sprintf(" AND (greeting_text ILIKE $%d OR voice_id ILIKE $%d OR language ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
configs := make([]types.VoiceAgentConfig, 0)
|
||||
for rows.Next() {
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHours []byte
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHours)
|
||||
configs = append(configs, c)
|
||||
}
|
||||
|
||||
return configs, total, nil
|
||||
}
|
||||
|
||||
// GetConfig gets a voice agent config by ID
|
||||
func (s *VoiceAgentService) GetConfig(ctx context.Context, id uuid.UUID) (*types.VoiceAgentConfig, error) {
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHours []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at
|
||||
FROM voice_agent_configs WHERE id = $1`, id).
|
||||
Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not found: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHours)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateConfig creates a new voice agent config
|
||||
func (s *VoiceAgentService) CreateConfig(ctx context.Context, req *types.VoiceAgentConfigCreate) (*types.VoiceAgentConfig, error) {
|
||||
// Set defaults
|
||||
greeting := req.GreetingText
|
||||
if greeting == "" {
|
||||
greeting = "Hello, how can I help you today?"
|
||||
}
|
||||
goodbye := req.GoodbyeText
|
||||
if goodbye == "" {
|
||||
goodbye = "Goodbye, have a great day."
|
||||
}
|
||||
voiceID := req.VoiceID
|
||||
if voiceID == "" {
|
||||
voiceID = "alloy"
|
||||
}
|
||||
lang := req.Language
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
maxDuration := 1800
|
||||
if req.MaxCallDurationSeconds != nil {
|
||||
maxDuration = *req.MaxCallDurationSeconds
|
||||
}
|
||||
silenceTimeout := 30
|
||||
if req.SilenceTimeoutSeconds != nil {
|
||||
silenceTimeout = *req.SilenceTimeoutSeconds
|
||||
}
|
||||
bargeIn := true
|
||||
if req.BargeInEnabled != nil {
|
||||
bargeIn = *req.BargeInEnabled
|
||||
}
|
||||
vadSens := req.VADSensitivity
|
||||
if vadSens == "" {
|
||||
vadSens = "medium"
|
||||
}
|
||||
transfer := true
|
||||
if req.TransferEnabled != nil {
|
||||
transfer = *req.TransferEnabled
|
||||
}
|
||||
bizHoursEnabled := false
|
||||
if req.BusinessHoursEnabled != nil {
|
||||
bizHoursEnabled = *req.BusinessHoursEnabled
|
||||
}
|
||||
bizHours := req.BusinessHours
|
||||
if len(bizHours) == 0 {
|
||||
bizHours = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHoursOut []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO voice_agent_configs (
|
||||
tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at`,
|
||||
req.TenantID, req.AgentID, greeting, goodbye,
|
||||
voiceID, lang,
|
||||
req.STTProvider, req.STTModel, req.TTSProvider, req.TTSModel,
|
||||
maxDuration, silenceTimeout,
|
||||
bargeIn, vadSens,
|
||||
transfer, req.TransferNumber,
|
||||
bizHoursEnabled, bizHours, req.AfterHoursText,
|
||||
).Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHoursOut, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHoursOut)
|
||||
s.logger.Info().Str("id", c.ID.String()).Str("agentId", c.AgentID.String()).Msg("Created voice agent config")
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpdateConfig updates a voice agent config
|
||||
func (s *VoiceAgentService) UpdateConfig(ctx context.Context, id uuid.UUID, req *types.VoiceAgentConfigUpdate) (*types.VoiceAgentConfig, error) {
|
||||
// Build dynamic SET clause
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
addField := func(clause string, val interface{}) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx))
|
||||
args = append(args, val)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.GreetingText != nil {
|
||||
addField("greeting_text", *req.GreetingText)
|
||||
}
|
||||
if req.GoodbyeText != nil {
|
||||
addField("goodbye_text", *req.GoodbyeText)
|
||||
}
|
||||
if req.VoiceID != nil {
|
||||
addField("voice_id", *req.VoiceID)
|
||||
}
|
||||
if req.Language != nil {
|
||||
addField("language", *req.Language)
|
||||
}
|
||||
if req.STTProvider != nil {
|
||||
addField("stt_provider", *req.STTProvider)
|
||||
}
|
||||
if req.STTModel != nil {
|
||||
addField("stt_model", *req.STTModel)
|
||||
}
|
||||
if req.TTSProvider != nil {
|
||||
addField("tts_provider", *req.TTSProvider)
|
||||
}
|
||||
if req.TTSModel != nil {
|
||||
addField("tts_model", *req.TTSModel)
|
||||
}
|
||||
if req.MaxCallDurationSeconds != nil {
|
||||
addField("max_call_duration_seconds", *req.MaxCallDurationSeconds)
|
||||
}
|
||||
if req.SilenceTimeoutSeconds != nil {
|
||||
addField("silence_timeout_seconds", *req.SilenceTimeoutSeconds)
|
||||
}
|
||||
if req.BargeInEnabled != nil {
|
||||
addField("barge_in_enabled", *req.BargeInEnabled)
|
||||
}
|
||||
if req.VADSensitivity != nil {
|
||||
addField("vad_sensitivity", *req.VADSensitivity)
|
||||
}
|
||||
if req.TransferEnabled != nil {
|
||||
addField("transfer_enabled", *req.TransferEnabled)
|
||||
}
|
||||
if req.TransferNumber != nil {
|
||||
addField("transfer_number", *req.TransferNumber)
|
||||
}
|
||||
if req.BusinessHoursEnabled != nil {
|
||||
addField("business_hours_enabled", *req.BusinessHoursEnabled)
|
||||
}
|
||||
if len(req.BusinessHours) > 0 {
|
||||
addField("business_hours", req.BusinessHours)
|
||||
}
|
||||
if req.AfterHoursText != nil {
|
||||
addField("after_hours_text", *req.AfterHoursText)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addField("is_active", *req.IsActive)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetConfig(ctx, id)
|
||||
}
|
||||
|
||||
// Always update updated_at
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
query := fmt.Sprintf("UPDATE voice_agent_configs SET %s WHERE id = $%d",
|
||||
joinClauses(setClauses), argIdx)
|
||||
args = append(args, id)
|
||||
query += ` RETURNING id, tenant_id, agent_id, greeting_text, goodbye_text,
|
||||
voice_id, language,
|
||||
stt_provider, stt_model, tts_provider, tts_model,
|
||||
max_call_duration_seconds, silence_timeout_seconds,
|
||||
barge_in_enabled, vad_sensitivity,
|
||||
transfer_enabled, transfer_number,
|
||||
business_hours_enabled, business_hours, after_hours_text,
|
||||
is_active, created_at, updated_at`
|
||||
|
||||
var c types.VoiceAgentConfig
|
||||
var businessHours []byte
|
||||
err := s.pool.QueryRow(ctx, query, args...).Scan(
|
||||
&c.ID, &c.TenantID, &c.AgentID, &c.GreetingText, &c.GoodbyeText,
|
||||
&c.VoiceID, &c.Language,
|
||||
&c.STTProvider, &c.STTModel, &c.TTSProvider, &c.TTSModel,
|
||||
&c.MaxCallDurationSeconds, &c.SilenceTimeoutSeconds,
|
||||
&c.BargeInEnabled, &c.VADSensitivity,
|
||||
&c.TransferEnabled, &c.TransferNumber,
|
||||
&c.BusinessHoursEnabled, &businessHours, &c.AfterHoursText,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
c.BusinessHours = json.RawMessage(businessHours)
|
||||
s.logger.Info().Str("id", id.String()).Msg("Updated voice agent config")
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DeleteConfig deletes a voice agent config
|
||||
func (s *VoiceAgentService) DeleteConfig(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM voice_agent_configs WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("config not found")
|
||||
}
|
||||
s.logger.Info().Str("id", id.String()).Msg("Deleted voice agent config")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice Sessions
|
||||
// ============================================================================
|
||||
|
||||
// ListSessions lists voice sessions for a specific agent
|
||||
func (s *VoiceAgentService) ListSessions(ctx context.Context, agentID uuid.UUID, params types.ListParams) ([]types.VoiceSession, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM voice_sessions WHERE agent_id = $1`, agentID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, agent_id, caller_number, called_number,
|
||||
asterisk_call_id, agent_session_id,
|
||||
total_turns, stt_provider, tts_provider,
|
||||
stt_audio_seconds, tts_characters,
|
||||
started_at, ended_at, end_reason, metadata, created_at
|
||||
FROM voice_sessions WHERE agent_id = $1
|
||||
ORDER BY started_at DESC LIMIT $2 OFFSET $3`,
|
||||
agentID, params.Limit, params.Offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := make([]types.VoiceSession, 0)
|
||||
for rows.Next() {
|
||||
var vs types.VoiceSession
|
||||
var metadata []byte
|
||||
if err := rows.Scan(
|
||||
&vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber,
|
||||
&vs.AsteriskCallID, &vs.AgentSessionID,
|
||||
&vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider,
|
||||
&vs.STTAudioSeconds, &vs.TTSCharacters,
|
||||
&vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
vs.Metadata = json.RawMessage(metadata)
|
||||
sessions = append(sessions, vs)
|
||||
}
|
||||
|
||||
return sessions, total, nil
|
||||
}
|
||||
|
||||
// GetSession gets a voice session by ID, including turns
|
||||
func (s *VoiceAgentService) GetSession(ctx context.Context, sessionID uuid.UUID) (*types.VoiceSession, error) {
|
||||
var vs types.VoiceSession
|
||||
var metadata []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, agent_id, caller_number, called_number,
|
||||
asterisk_call_id, agent_session_id,
|
||||
total_turns, stt_provider, tts_provider,
|
||||
stt_audio_seconds, tts_characters,
|
||||
started_at, ended_at, end_reason, metadata, created_at
|
||||
FROM voice_sessions WHERE id = $1`, sessionID).
|
||||
Scan(
|
||||
&vs.ID, &vs.TenantID, &vs.AgentID, &vs.CallerNumber, &vs.CalledNumber,
|
||||
&vs.AsteriskCallID, &vs.AgentSessionID,
|
||||
&vs.TotalTurns, &vs.STTProvider, &vs.TTSProvider,
|
||||
&vs.STTAudioSeconds, &vs.TTSCharacters,
|
||||
&vs.StartedAt, &vs.EndedAt, &vs.EndReason, &metadata, &vs.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session not found: %w", err)
|
||||
}
|
||||
vs.Metadata = json.RawMessage(metadata)
|
||||
|
||||
// Fetch turns
|
||||
turnRows, err := s.pool.Query(ctx,
|
||||
`SELECT id, session_id, turn_number, role, text,
|
||||
stt_confidence, agent_latency_ms, was_interrupted, created_at
|
||||
FROM voice_session_turns WHERE session_id = $1
|
||||
ORDER BY turn_number ASC`, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("turns query failed: %w", err)
|
||||
}
|
||||
defer turnRows.Close()
|
||||
|
||||
vs.Turns = make([]types.VoiceSessionTurn, 0)
|
||||
for turnRows.Next() {
|
||||
var t types.VoiceSessionTurn
|
||||
if err := turnRows.Scan(
|
||||
&t.ID, &t.SessionID, &t.TurnNumber, &t.Role, &t.Text,
|
||||
&t.STTConfidence, &t.AgentLatencyMs, &t.WasInterrupted, &t.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("turn scan failed: %w", err)
|
||||
}
|
||||
vs.Turns = append(vs.Turns, t)
|
||||
}
|
||||
|
||||
return &vs, nil
|
||||
}
|
||||
|
||||
// joinClauses joins SQL SET clauses with commas
|
||||
func joinClauses(clauses []string) string {
|
||||
result := ""
|
||||
for i, c := range clauses {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += c
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user