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 }