Initial project snapshot
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user