345 lines
11 KiB
Go
345 lines
11 KiB
Go
package identitysource
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
|
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
|
)
|
|
|
|
type Module struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
type IdentitySource struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organization_id"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Config json.RawMessage `json:"config"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type IdentityMapping struct {
|
|
ID string `json:"id"`
|
|
IdentitySourceID string `json:"identity_source_id"`
|
|
MappingType string `json:"mapping_type"`
|
|
ExternalSelector json.RawMessage `json:"external_selector"`
|
|
InternalTarget json.RawMessage `json:"internal_target"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type upsertIdentitySourceRequest struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OrganizationID string `json:"organization_id"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Config json.RawMessage `json:"config"`
|
|
IdentityMappings []struct {
|
|
MappingType string `json:"mapping_type"`
|
|
ExternalSelector json.RawMessage `json:"external_selector"`
|
|
InternalTarget json.RawMessage `json:"internal_target"`
|
|
} `json:"identity_mappings"`
|
|
}
|
|
|
|
func NewModule(deps module.Dependencies) *Module {
|
|
return &Module{db: deps.Infra.DB}
|
|
}
|
|
|
|
func (m *Module) Name() string {
|
|
return "identitysource"
|
|
}
|
|
|
|
func (m *Module) RegisterRoutes(router chi.Router) {
|
|
router.Route("/identity-sources", func(r chi.Router) {
|
|
r.Get("/", m.listIdentitySources)
|
|
r.Post("/", m.createIdentitySource)
|
|
r.Get("/{identitySourceID}", m.getIdentitySource)
|
|
r.Put("/{identitySourceID}", m.updateIdentitySource)
|
|
})
|
|
}
|
|
|
|
func (m *Module) listIdentitySources(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.URL.Query().Get("organization_id")
|
|
if orgID == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "organization_id is required")
|
|
return
|
|
}
|
|
rows, err := m.db.Query(r.Context(), `
|
|
SELECT id, organization_id, kind, name, status, config, created_at, updated_at
|
|
FROM identity_sources
|
|
WHERE organization_id = $1
|
|
ORDER BY created_at DESC
|
|
`, orgID)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var items []IdentitySource
|
|
for rows.Next() {
|
|
item, err := scanIdentitySource(rows)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"identity_sources": items})
|
|
}
|
|
|
|
func (m *Module) getIdentitySource(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "identitySourceID")
|
|
item, err := m.getByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
httpx.WriteError(w, http.StatusNotFound, "identity source not found")
|
|
return
|
|
}
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
mappings, err := m.listMappings(r.Context(), id)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"identity_source": item,
|
|
"identity_mappings": mappings,
|
|
})
|
|
}
|
|
|
|
func (m *Module) createIdentitySource(w http.ResponseWriter, r *http.Request) {
|
|
req, err := decodeRequest(r)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
item := IdentitySource{
|
|
ID: uuid.NewString(),
|
|
OrganizationID: req.OrganizationID,
|
|
Kind: req.Kind,
|
|
Name: req.Name,
|
|
Status: req.Status,
|
|
Config: req.Config,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
tx, err := m.db.Begin(r.Context())
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO identity_sources (id, organization_id, kind, name, status, config, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
|
|
`, item.ID, item.OrganizationID, item.Kind, item.Name, item.Status, []byte(item.Config), item.CreatedAt, item.UpdatedAt); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
mappings, err := upsertMappings(r.Context(), tx, item.ID, req.IdentityMappings)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{
|
|
"identity_source": item,
|
|
"identity_mappings": mappings,
|
|
})
|
|
}
|
|
|
|
func (m *Module) updateIdentitySource(w http.ResponseWriter, r *http.Request) {
|
|
req, err := decodeRequest(r)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "identitySourceID")
|
|
tx, err := m.db.Begin(r.Context())
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
tag, err := tx.Exec(r.Context(), `
|
|
UPDATE identity_sources
|
|
SET organization_id = $2, kind = $3, name = $4, status = $5, config = $6::jsonb, updated_at = $7
|
|
WHERE id = $1
|
|
`, id, req.OrganizationID, req.Kind, req.Name, req.Status, []byte(req.Config), time.Now().UTC())
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
httpx.WriteError(w, http.StatusNotFound, "identity source not found")
|
|
return
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `DELETE FROM identity_mappings WHERE identity_source_id = $1`, id); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
mappings, err := upsertMappings(r.Context(), tx, id, req.IdentityMappings)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
item, err := m.getByID(r.Context(), id)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"identity_source": item,
|
|
"identity_mappings": mappings,
|
|
})
|
|
}
|
|
|
|
func decodeRequest(r *http.Request) (*upsertIdentitySourceRequest, error) {
|
|
var req upsertIdentitySourceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
return nil, errors.New("invalid identity source payload")
|
|
}
|
|
if req.ActorUserID == "" || req.OrganizationID == "" || req.Kind == "" || req.Name == "" {
|
|
return nil, errors.New("actor_user_id, organization_id, kind, and name are required")
|
|
}
|
|
if req.Status == "" {
|
|
req.Status = "active"
|
|
}
|
|
if len(req.Config) == 0 {
|
|
req.Config = json.RawMessage(`{}`)
|
|
}
|
|
if !json.Valid(req.Config) {
|
|
return nil, errors.New("config must be valid json")
|
|
}
|
|
for _, mapping := range req.IdentityMappings {
|
|
if len(mapping.ExternalSelector) == 0 {
|
|
mapping.ExternalSelector = json.RawMessage(`{}`)
|
|
}
|
|
if len(mapping.InternalTarget) == 0 {
|
|
mapping.InternalTarget = json.RawMessage(`{}`)
|
|
}
|
|
}
|
|
return &req, nil
|
|
}
|
|
|
|
func (m *Module) getByID(ctx context.Context, id string) (IdentitySource, error) {
|
|
row := m.db.QueryRow(ctx, `
|
|
SELECT id, organization_id, kind, name, status, config, created_at, updated_at
|
|
FROM identity_sources
|
|
WHERE id = $1
|
|
`, id)
|
|
return scanIdentitySource(row)
|
|
}
|
|
|
|
func (m *Module) listMappings(ctx context.Context, sourceID string) ([]IdentityMapping, error) {
|
|
rows, err := m.db.Query(ctx, `
|
|
SELECT id, identity_source_id, mapping_type, external_selector, internal_target, created_at, updated_at
|
|
FROM identity_mappings
|
|
WHERE identity_source_id = $1
|
|
ORDER BY created_at ASC
|
|
`, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var mappings []IdentityMapping
|
|
for rows.Next() {
|
|
item, err := scanIdentityMapping(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mappings = append(mappings, item)
|
|
}
|
|
return mappings, rows.Err()
|
|
}
|
|
|
|
func upsertMappings(ctx context.Context, tx pgx.Tx, sourceID string, requested []struct {
|
|
MappingType string `json:"mapping_type"`
|
|
ExternalSelector json.RawMessage `json:"external_selector"`
|
|
InternalTarget json.RawMessage `json:"internal_target"`
|
|
}) ([]IdentityMapping, error) {
|
|
now := time.Now().UTC()
|
|
items := make([]IdentityMapping, 0, len(requested))
|
|
for _, mapping := range requested {
|
|
external := mapping.ExternalSelector
|
|
if len(external) == 0 {
|
|
external = json.RawMessage(`{}`)
|
|
}
|
|
internal := mapping.InternalTarget
|
|
if len(internal) == 0 {
|
|
internal = json.RawMessage(`{}`)
|
|
}
|
|
item := IdentityMapping{
|
|
ID: uuid.NewString(),
|
|
IdentitySourceID: sourceID,
|
|
MappingType: mapping.MappingType,
|
|
ExternalSelector: external,
|
|
InternalTarget: internal,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO identity_mappings (
|
|
id, identity_source_id, mapping_type, external_selector, internal_target, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7)
|
|
`, item.ID, item.IdentitySourceID, item.MappingType, []byte(item.ExternalSelector), []byte(item.InternalTarget), item.CreatedAt, item.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanIdentitySource(row rowScanner) (IdentitySource, error) {
|
|
var item IdentitySource
|
|
if err := row.Scan(&item.ID, &item.OrganizationID, &item.Kind, &item.Name, &item.Status, &item.Config, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
|
return IdentitySource{}, err
|
|
}
|
|
if len(item.Config) == 0 {
|
|
item.Config = json.RawMessage(`{}`)
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func scanIdentityMapping(row rowScanner) (IdentityMapping, error) {
|
|
var item IdentityMapping
|
|
if err := row.Scan(&item.ID, &item.IdentitySourceID, &item.MappingType, &item.ExternalSelector, &item.InternalTarget, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
|
return IdentityMapping{}, err
|
|
}
|
|
if len(item.ExternalSelector) == 0 {
|
|
item.ExternalSelector = json.RawMessage(`{}`)
|
|
}
|
|
if len(item.InternalTarget) == 0 {
|
|
item.InternalTarget = json.RawMessage(`{}`)
|
|
}
|
|
return item, nil
|
|
}
|