diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 224aeed10..6f00fc59c 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -338,6 +338,7 @@ jobs: VITE_API_BASE_URL=/backend/api VITE_SIGNIN_REDIRECT_URI=login-redirect VITE_SIGNOUT_REDIRECT_URI=login + VITE_SHOP_BASE_URL=https://nethshop.nethesis.it cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend diff --git a/.gitignore b/.gitignore index 625d84b73..06125de1d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ collect/configs/*.yml config.json config.yaml +# Local dev TLS certs (mkcert-generated per-machine — see proxy/Makefile dev-setup) +proxy/*.pem + # IDE .vscode/ .idea/ diff --git a/backend/database/migrations/037_system_entitlements.sql b/backend/database/migrations/037_system_entitlements.sql new file mode 100644 index 000000000..bebc20d08 --- /dev/null +++ b/backend/database/migrations/037_system_entitlements.sql @@ -0,0 +1,109 @@ +-- Migration 037: entitlement catalog + system_entitlements (granular add-on licensing) +-- Replaces the transitional grant-all /auth broker. +-- +-- entitlement_catalog: the set of sellable/grantable add-ons, DB-driven so the +-- owner can add types (and, in fase 3, map shop products) without a deploy. +-- `scoped` marks add-ons that can be granted to a single application instance +-- of a system (e.g. the "chat" module of nethvoice5 on an NS8 cluster). +-- +-- system_entitlements: one row grants one add-on to one system, optionally +-- narrowed to one application instance via `scope` ('' = whole system). +-- Collect's native /auth/service/[?scope=] answers 200/403 from +-- here: a system-wide grant also covers every instance (fallback). The +-- backend exposes CRUD under /api/systems/:id/entitlements (manual writes = +-- owner org or Super Admin only: licensing is controlled by Nethesis, +-- downstream orgs buy through the shop). Sources: 'legacy-import' (one-shot + +-- cron delta from the old my service_server), 'shop' (nethshop +-- order/subscription), 'manual'. Renewals UPDATE valid_until in place — one +-- row per (system, entitlement, scope), history stays in the audit log. +-- Active = not revoked AND (valid_until IS NULL OR valid_until > now()); +-- valid_until NULL = no expiry (all legacy imports are perpetual by decision +-- of 2026-07-15). + +CREATE TABLE IF NOT EXISTS entitlement_catalog ( + id VARCHAR(100) PRIMARY KEY, + display_name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + scoped BOOLEAN NOT NULL DEFAULT FALSE, + kind VARCHAR(20) NOT NULL DEFAULT 'service', + system_type VARCHAR(50) NOT NULL DEFAULT '', + legacy_alias VARCHAR(100) NOT NULL DEFAULT '', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_entitlement_catalog_legacy_alias + ON entitlement_catalog(legacy_alias) WHERE legacy_alias <> ''; + +COMMENT ON TABLE entitlement_catalog IS 'Grantable add-on types. Id convention: nsec- (firewall services), ns8- (app enablement on a cluster), - (per-app-instance modules, scoped=TRUE)'; +COMMENT ON COLUMN entitlement_catalog.scoped IS 'TRUE = grantable per application instance of a system (scope on the grant); FALSE = system-wide only'; +COMMENT ON COLUMN entitlement_catalog.kind IS 'service (nsec-, firewall add-on, system-wide) | module (-, add-on for one application instance of an NS8 cluster) — both sellable on the shop'; +COMMENT ON COLUMN entitlement_catalog.system_type IS 'System type the add-on applies to: nsec | ns8; empty = any. Grants are refused on mismatching systems and the UI/shop only offer pertinent add-ons'; +COMMENT ON COLUMN entitlement_catalog.legacy_alias IS 'Legacy wire id the appliance feeds still call on /auth/service/ (e.g. ng-blacklist for nsec-blacklist); collect resolves it to the canonical id'; + +INSERT INTO entitlement_catalog (id, display_name, description, scoped, kind, system_type, legacy_alias) VALUES + ('nsec-blacklist', 'Advanced Threat Shield', 'Enterprise blacklist feeds (bl.nethesis.it)', FALSE, 'service', 'nsec', 'ng-blacklist'), + ('nsec-ha', 'High Availability', 'High availability (HA)', FALSE, 'service', 'nsec', 'ng-ha'), + ('nsec-sandbox', 'Sandbox', 'Sandbox', FALSE, 'service', 'nsec', 'ng-sandbox') +ON CONFLICT (id) DO NOTHING; + +-- entitlement_availability: OPTIONAL commercial restriction. A catalog item +-- is available to EVERYONE by default; when rules exist for an item, only +-- the matching role/orgs may buy it (org_role XOR organization_id per row). +-- This table does NOT affect /auth enforcement: an existing grant works +-- regardless. +CREATE TABLE IF NOT EXISTS entitlement_availability ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entitlement VARCHAR(100) NOT NULL REFERENCES entitlement_catalog(id) ON DELETE CASCADE, + org_role VARCHAR(50) NOT NULL DEFAULT '', + organization_id VARCHAR(255) NOT NULL DEFAULT '', + created_by JSONB, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT entitlement_availability_unique UNIQUE (entitlement, org_role, organization_id), + CONSTRAINT entitlement_availability_target CHECK ((org_role <> '') <> (organization_id <> '')) +); + +CREATE INDEX IF NOT EXISTS idx_entitlement_availability_ent ON entitlement_availability(entitlement); + +COMMENT ON TABLE entitlement_availability IS 'Optional commercial restriction: no rows = item available to everyone; rules restrict to matching role/orgs. Does not affect /auth enforcement'; +COMMENT ON COLUMN entitlement_availability.org_role IS 'Role-wide unlock: distributor | reseller | customer (empty when organization_id is set)'; +COMMENT ON COLUMN entitlement_availability.organization_id IS 'Org-specific unlock (empty when org_role is set)'; + +CREATE TABLE IF NOT EXISTS system_entitlements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + system_id VARCHAR(255) NOT NULL REFERENCES systems(id) ON DELETE CASCADE, + entitlement VARCHAR(100) NOT NULL REFERENCES entitlement_catalog(id), + scope VARCHAR(255) NOT NULL DEFAULT '', + source VARCHAR(50) NOT NULL DEFAULT 'manual', + source_ref VARCHAR(255), + valid_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + valid_until TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE, + revoked_source VARCHAR(50), + pending_ref VARCHAR(255), + pending_since TIMESTAMP WITH TIME ZONE, + created_by JSONB, + purchased_by JSONB, + variant JSONB, + renewal_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT system_entitlements_unique UNIQUE (system_id, entitlement, scope) +); + +CREATE INDEX IF NOT EXISTS idx_system_entitlements_system_id ON system_entitlements(system_id); +CREATE INDEX IF NOT EXISTS idx_system_entitlements_entitlement ON system_entitlements(entitlement); + +COMMENT ON TABLE system_entitlements IS 'Granular add-on grants per system (ng-blacklist & co.); checked by collect /auth/service/[?scope=]'; +COMMENT ON COLUMN system_entitlements.entitlement IS 'Add-on id from entitlement_catalog (same ids as the legacy service table)'; +COMMENT ON COLUMN system_entitlements.scope IS 'Application instance the grant is narrowed to (e.g. nethvoice5 on an NS8 cluster); empty = whole system'; +COMMENT ON COLUMN system_entitlements.source IS 'How the grant was created: legacy-import | shop | manual'; +COMMENT ON COLUMN system_entitlements.source_ref IS 'Reference in the source system (e.g. nethshop order/subscription id, legacy service_server id)'; +COMMENT ON COLUMN system_entitlements.valid_until IS 'Expiry; NULL = perpetual (legacy imports). Renewals push this forward in place'; +COMMENT ON COLUMN system_entitlements.revoked_at IS 'Set on revoke (DELETE endpoint / subscription cancelled); row kept for audit'; +COMMENT ON COLUMN system_entitlements.revoked_source IS 'Who revoked: manual (admin DELETE/PUT — deliberate, not re-buyable) | shop (deactivate webhook: subscription cancelled/payment failed — re-buyable). NULL when not revoked'; +COMMENT ON COLUMN system_entitlements.pending_ref IS 'Shop order awaiting payment (set at checkout, cleared on activate/cancel). Display-only: enforcement ignores it. A never-activated pending stub has valid_until = valid_from'; +COMMENT ON COLUMN system_entitlements.created_by IS 'Actor snapshot (user/org or shop M2M) that created the grant'; +COMMENT ON COLUMN system_entitlements.purchased_by IS 'Snapshot of the my user that BOUGHT the grant on the shop, resolved from the order customer email (webhook activation or legacy-import backfill): {logto_id, name, email, organization_id, organization_name, org_role, user_roles}. {email} only when the address matches no my user; NULL for manual grants and legacy rows without an order'; +COMMENT ON COLUMN system_entitlements.variant IS 'Shop variation (tier) of the purchased product line: {id, sku, label} (e.g. label "16-30 device"). Display metadata only — the add-on mapping stays on the parent product and /auth enforcement ignores it. Refreshed by activate (upgrades/downgrades follow renewals); NULL for manual grants and simple products'; +COMMENT ON COLUMN system_entitlements.renewal_count IS 'Paid shop orders on this grant beyond the first: incremented by activate when source_ref CHANGES (webhook retries on the same order never double-count). 0 = first period'; diff --git a/backend/database/migrations/037_system_entitlements_rollback.sql b/backend/database/migrations/037_system_entitlements_rollback.sql new file mode 100644 index 000000000..e3c03887d --- /dev/null +++ b/backend/database/migrations/037_system_entitlements_rollback.sql @@ -0,0 +1,4 @@ +-- Rollback migration 037 +DROP TABLE IF EXISTS system_entitlements; +DROP TABLE IF EXISTS entitlement_availability; +DROP TABLE IF EXISTS entitlement_catalog; diff --git a/backend/database/schema.sql b/backend/database/schema.sql index fb4894b46..7c999bfb5 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -1185,3 +1185,102 @@ CREATE INDEX IF NOT EXISTS idx_api_key_audit_user ON api_key_audit(user_id, crea CREATE INDEX IF NOT EXISTS idx_api_key_audit_org ON api_key_audit(organization_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_api_key_audit_key ON api_key_audit(api_key_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_api_key_audit_event ON api_key_audit(event); + +-- ============================================================================= +-- ENTITLEMENTS (granular add-on licensing, migration 037) +-- ============================================================================= +-- entitlement_catalog: DB-driven set of grantable add-on types. The 3 ng-* +-- ids are the legacy wire ids the feeds call — do not rename. New ids follow +-- the convention: nsec- / ns8- / - (scoped=TRUE). +-- system_entitlements: one row grants one add-on to one system, optionally +-- narrowed to one application instance via scope ('' = whole system). +-- Collect's native /auth/service/[?scope=] answers 200/403 from here; +-- a system-wide grant also covers every instance (fallback). +-- Active = revoked_at IS NULL AND (valid_until IS NULL OR valid_until > now()); +-- valid_until NULL = perpetual (legacy imports). Renewals UPDATE valid_until +-- in place — one row per (system, entitlement, scope). + +CREATE TABLE IF NOT EXISTS entitlement_catalog ( + id VARCHAR(100) PRIMARY KEY, + display_name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + scoped BOOLEAN NOT NULL DEFAULT FALSE, + kind VARCHAR(20) NOT NULL DEFAULT 'service', + system_type VARCHAR(50) NOT NULL DEFAULT '', + legacy_alias VARCHAR(100) NOT NULL DEFAULT '', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_entitlement_catalog_legacy_alias + ON entitlement_catalog(legacy_alias) WHERE legacy_alias <> ''; + +COMMENT ON TABLE entitlement_catalog IS 'Grantable add-on types. Id convention: nsec- / ns8- / - (scoped)'; +COMMENT ON COLUMN entitlement_catalog.scoped IS 'TRUE = grantable per application instance of a system (scope on the grant); FALSE = system-wide only'; +COMMENT ON COLUMN entitlement_catalog.kind IS 'service (nsec-, firewall add-on, system-wide) | module (-, add-on for one application instance of an NS8 cluster) — both sellable on the shop'; +COMMENT ON COLUMN entitlement_catalog.system_type IS 'System type the add-on applies to: nsec | ns8; empty = any. Grants are refused on mismatching systems and the UI/shop only offer pertinent add-ons'; +COMMENT ON COLUMN entitlement_catalog.legacy_alias IS 'Legacy wire id the appliance feeds still call on /auth/service/; collect resolves it to the canonical id'; + +INSERT INTO entitlement_catalog (id, display_name, description, scoped, kind, system_type, legacy_alias) VALUES + ('nsec-blacklist', 'Advanced Threat Shield', 'Enterprise blacklist feeds (bl.nethesis.it)', FALSE, 'service', 'nsec', 'ng-blacklist'), + ('nsec-ha', 'High Availability', 'High availability (HA)', FALSE, 'service', 'nsec', 'ng-ha'), + ('nsec-sandbox', 'Sandbox', 'Sandbox', FALSE, 'service', 'nsec', 'ng-sandbox') +ON CONFLICT (id) DO NOTHING; + +-- OPTIONAL commercial restriction: sellable items are available to everyone +-- by default; rules, when present, restrict to matching role/orgs. +CREATE TABLE IF NOT EXISTS entitlement_availability ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entitlement VARCHAR(100) NOT NULL REFERENCES entitlement_catalog(id) ON DELETE CASCADE, + org_role VARCHAR(50) NOT NULL DEFAULT '', + organization_id VARCHAR(255) NOT NULL DEFAULT '', + created_by JSONB, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT entitlement_availability_unique UNIQUE (entitlement, org_role, organization_id), + CONSTRAINT entitlement_availability_target CHECK ((org_role <> '') <> (organization_id <> '')) +); + +CREATE INDEX IF NOT EXISTS idx_entitlement_availability_ent ON entitlement_availability(entitlement); + +COMMENT ON TABLE entitlement_availability IS 'Optional commercial restriction: no rows = item available to everyone; rules restrict to matching role/orgs. Does not affect /auth enforcement'; +COMMENT ON COLUMN entitlement_availability.org_role IS 'Role-wide unlock: distributor | reseller | customer (empty when organization_id is set)'; +COMMENT ON COLUMN entitlement_availability.organization_id IS 'Org-specific unlock (empty when org_role is set)'; + +CREATE TABLE IF NOT EXISTS system_entitlements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + system_id VARCHAR(255) NOT NULL REFERENCES systems(id) ON DELETE CASCADE, + entitlement VARCHAR(100) NOT NULL REFERENCES entitlement_catalog(id), + scope VARCHAR(255) NOT NULL DEFAULT '', + source VARCHAR(50) NOT NULL DEFAULT 'manual', + source_ref VARCHAR(255), + valid_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + valid_until TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE, + revoked_source VARCHAR(50), + pending_ref VARCHAR(255), + pending_since TIMESTAMP WITH TIME ZONE, + created_by JSONB, + purchased_by JSONB, + variant JSONB, + renewal_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT system_entitlements_unique UNIQUE (system_id, entitlement, scope) +); + +CREATE INDEX IF NOT EXISTS idx_system_entitlements_system_id ON system_entitlements(system_id); +CREATE INDEX IF NOT EXISTS idx_system_entitlements_entitlement ON system_entitlements(entitlement); + +COMMENT ON TABLE system_entitlements IS 'Granular add-on grants per system; checked by collect /auth/service/[?scope=]'; +COMMENT ON COLUMN system_entitlements.entitlement IS 'Add-on id from entitlement_catalog (same ids as the legacy service table for ng-*)'; +COMMENT ON COLUMN system_entitlements.scope IS 'Application instance the grant is narrowed to (e.g. nethvoice5 on an NS8 cluster); empty = whole system'; +COMMENT ON COLUMN system_entitlements.source IS 'How the grant was created: legacy-import | shop | manual'; +COMMENT ON COLUMN system_entitlements.source_ref IS 'Reference in the source system (e.g. nethshop order/subscription id, legacy service_server id)'; +COMMENT ON COLUMN system_entitlements.valid_until IS 'Expiry; NULL = perpetual (legacy imports). Renewals push this forward in place'; +COMMENT ON COLUMN system_entitlements.revoked_at IS 'Set on revoke (DELETE endpoint / subscription cancelled); row kept for audit'; +COMMENT ON COLUMN system_entitlements.revoked_source IS 'Who revoked: manual (admin DELETE/PUT — deliberate, not re-buyable) | shop (deactivate webhook: subscription cancelled/payment failed — re-buyable). NULL when not revoked'; +COMMENT ON COLUMN system_entitlements.pending_ref IS 'Shop order awaiting payment (set at checkout, cleared on activate/cancel). Display-only: enforcement ignores it. A never-activated pending stub has valid_until = valid_from'; +COMMENT ON COLUMN system_entitlements.created_by IS 'Actor snapshot (user/org or shop M2M) that created the grant'; +COMMENT ON COLUMN system_entitlements.purchased_by IS 'Snapshot of the my user that BOUGHT the grant on the shop, resolved from the order customer email (webhook activation or legacy-import backfill): {logto_id, name, email, organization_id, organization_name, org_role, user_roles}. {email} only when the address matches no my user; NULL for manual grants and legacy rows without an order'; +COMMENT ON COLUMN system_entitlements.variant IS 'Shop variation (tier) of the purchased product line: {id, sku, label} (e.g. label "16-30 device"). Display metadata only — the add-on mapping stays on the parent product and /auth enforcement ignores it. Refreshed by activate (upgrades/downgrades follow renewals); NULL for manual grants and simple products'; +COMMENT ON COLUMN system_entitlements.renewal_count IS 'Paid shop orders on this grant beyond the first: incremented by activate when source_ref CHANGES (webhook retries on the same order never double-count). 0 = first period'; diff --git a/backend/entities/local_system_entitlements.go b/backend/entities/local_system_entitlements.go new file mode 100644 index 000000000..36e17a087 --- /dev/null +++ b/backend/entities/local_system_entitlements.go @@ -0,0 +1,961 @@ +/* +Copyright (C) 2026 Nethesis S.r.l. +SPDX-License-Identifier: AGPL-3.0-or-later +*/ + +package entities + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/lib/pq" + + "github.com/nethesis/my/backend/database" + "github.com/nethesis/my/backend/models" +) + +// ErrEntitlementExists is returned by Create when the (system, entitlement, +// scope) tuple already has a row — renewals go through Update, never +// duplicate rows. +var ErrEntitlementExists = fmt.Errorf("entitlement already exists for this system") + +// ErrEntitlementNotFound is returned when the (system, entitlement, scope) +// tuple has no row. +var ErrEntitlementNotFound = fmt.Errorf("entitlement not found for this system") + +// ErrCatalogItemExists / ErrCatalogItemNotFound / ErrCatalogItemInUse are the +// catalog counterparts. +var ErrCatalogItemExists = fmt.Errorf("catalog item already exists") +var ErrCatalogItemNotFound = fmt.Errorf("catalog item not found") +var ErrCatalogItemInUse = fmt.Errorf("catalog item is referenced by existing grants") + +// LocalEntitlementCatalogRepository reads / writes entitlement_catalog. +type LocalEntitlementCatalogRepository struct { + db *sql.DB +} + +func NewLocalEntitlementCatalogRepository() *LocalEntitlementCatalogRepository { + return &LocalEntitlementCatalogRepository{db: database.DB} +} + +func scanCatalogItem(scanner interface{ Scan(...interface{}) error }) (*models.EntitlementCatalogItem, error) { + var item models.EntitlementCatalogItem + err := scanner.Scan(&item.ID, &item.DisplayName, &item.Description, &item.Scoped, &item.Kind, &item.SystemType, &item.LegacyAlias, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, err + } + return &item, nil +} + +// List returns the whole catalog ordered by id. +func (r *LocalEntitlementCatalogRepository) List() ([]*models.EntitlementCatalogItem, error) { + rows, err := r.db.Query( + `SELECT id, display_name, description, scoped, kind, system_type, legacy_alias, created_at, updated_at + FROM entitlement_catalog ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("failed to list entitlement catalog: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []*models.EntitlementCatalogItem{} + for rows.Next() { + item, err := scanCatalogItem(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan catalog item: %w", err) + } + out = append(out, item) + } + return out, rows.Err() +} + +// Get returns one catalog item by id. +func (r *LocalEntitlementCatalogRepository) Get(id string) (*models.EntitlementCatalogItem, error) { + item, err := scanCatalogItem(r.db.QueryRow( + `SELECT id, display_name, description, scoped, kind, system_type, legacy_alias, created_at, updated_at + FROM entitlement_catalog WHERE id = $1`, id)) + if err == sql.ErrNoRows { + return nil, ErrCatalogItemNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get catalog item: %w", err) + } + return item, nil +} + +// Resolve returns the catalog item matching the given id, accepting either +// the canonical id or the legacy wire alias (e.g. ng-blacklist for +// nsec-blacklist). +func (r *LocalEntitlementCatalogRepository) Resolve(idOrAlias string) (*models.EntitlementCatalogItem, error) { + item, err := scanCatalogItem(r.db.QueryRow( + `SELECT id, display_name, description, scoped, kind, system_type, legacy_alias, created_at, updated_at + FROM entitlement_catalog WHERE id = $1 OR (legacy_alias <> '' AND legacy_alias = $1)`, idOrAlias)) + if err == sql.ErrNoRows { + return nil, ErrCatalogItemNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to resolve catalog item: %w", err) + } + return item, nil +} + +// Create adds a new add-on type. +func (r *LocalEntitlementCatalogRepository) Create(req *models.CreateEntitlementCatalogRequest) (*models.EntitlementCatalogItem, error) { + item, err := scanCatalogItem(r.db.QueryRow( + `INSERT INTO entitlement_catalog (id, display_name, description, scoped, kind, system_type, legacy_alias) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, display_name, description, scoped, kind, system_type, legacy_alias, created_at, updated_at`, + req.ID, req.DisplayName, req.Description, req.Scoped, req.Kind, req.SystemType, req.LegacyAlias)) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return nil, ErrCatalogItemExists + } + return nil, fmt.Errorf("failed to create catalog item: %w", err) + } + return item, nil +} + +// Update changes the display fields of a catalog item (id and scoped are +// immutable). +func (r *LocalEntitlementCatalogRepository) Update(id string, displayName, description *string) (*models.EntitlementCatalogItem, error) { + item, err := scanCatalogItem(r.db.QueryRow( + `UPDATE entitlement_catalog SET + display_name = COALESCE($2, display_name), + description = COALESCE($3, description), + updated_at = NOW() + WHERE id = $1 + RETURNING id, display_name, description, scoped, kind, system_type, legacy_alias, created_at, updated_at`, + id, displayName, description)) + if err == sql.ErrNoRows { + return nil, ErrCatalogItemNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to update catalog item: %w", err) + } + return item, nil +} + +// Delete removes a catalog item; refused while grants reference it (FK). +func (r *LocalEntitlementCatalogRepository) Delete(id string) error { + res, err := r.db.Exec(`DELETE FROM entitlement_catalog WHERE id = $1`, id) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23503" { + return ErrCatalogItemInUse + } + return fmt.Errorf("failed to delete catalog item: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrCatalogItemNotFound + } + return nil +} + +// ErrAvailabilityExists / ErrAvailabilityNotFound are the availability +// counterparts. +var ErrAvailabilityExists = fmt.Errorf("availability rule already exists") +var ErrAvailabilityNotFound = fmt.Errorf("availability rule not found") + +// LocalEntitlementAvailabilityRepository reads / writes +// entitlement_availability (commercial unlocks). +type LocalEntitlementAvailabilityRepository struct { + db *sql.DB +} + +func NewLocalEntitlementAvailabilityRepository() *LocalEntitlementAvailabilityRepository { + return &LocalEntitlementAvailabilityRepository{db: database.DB} +} + +func scanAvailability(scanner interface{ Scan(...interface{}) error }) (*models.EntitlementAvailability, error) { + var a models.EntitlementAvailability + var createdBy []byte + err := scanner.Scan(&a.ID, &a.Entitlement, &a.OrgRole, &a.OrganizationID, &createdBy, &a.CreatedAt) + if err != nil { + return nil, err + } + if len(createdBy) > 0 { + _ = json.Unmarshal(createdBy, &a.CreatedBy) + } + return &a, nil +} + +// ListByEntitlement returns the unlock rules of one catalog item. +func (r *LocalEntitlementAvailabilityRepository) ListByEntitlement(entitlement string) ([]*models.EntitlementAvailability, error) { + rows, err := r.db.Query( + `SELECT id, entitlement, org_role, organization_id, created_by, created_at + FROM entitlement_availability WHERE entitlement = $1 + ORDER BY org_role, organization_id`, entitlement) + if err != nil { + return nil, fmt.Errorf("failed to list availability: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []*models.EntitlementAvailability{} + for rows.Next() { + a, err := scanAvailability(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan availability: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} + +// ListAvailableFor returns the sellable catalog items (services and +// modules; apps are enablement-only) available to an organization. A newly +// created item is available to EVERYONE by default; availability rules, when +// present for an item, RESTRICT it to the matching role/orgs. +func (r *LocalEntitlementAvailabilityRepository) ListAvailableFor(orgRole, orgID string) ([]*models.EntitlementCatalogItem, error) { + rows, err := r.db.Query( + `SELECT c.id, c.display_name, c.description, c.scoped, c.kind, c.system_type, c.legacy_alias, c.created_at, c.updated_at + FROM entitlement_catalog c + WHERE c.kind IN ('service', 'module') + AND (NOT EXISTS (SELECT 1 FROM entitlement_availability a WHERE a.entitlement = c.id) + OR EXISTS (SELECT 1 FROM entitlement_availability a + WHERE a.entitlement = c.id + AND (a.org_role = LOWER($1) OR a.organization_id = $2))) + ORDER BY c.id`, orgRole, orgID) + if err != nil { + return nil, fmt.Errorf("failed to list available entitlements: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []*models.EntitlementCatalogItem{} + for rows.Next() { + item, err := scanCatalogItem(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan catalog item: %w", err) + } + out = append(out, item) + } + return out, rows.Err() +} + +// Add creates one unlock rule (role-wide or org-specific). +func (r *LocalEntitlementAvailabilityRepository) Add(entitlement, orgRole, orgID string, createdBy map[string]interface{}) (*models.EntitlementAvailability, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + a, err := scanAvailability(r.db.QueryRow( + `INSERT INTO entitlement_availability (entitlement, org_role, organization_id, created_by) + VALUES ($1, LOWER($2), $3, $4) + RETURNING id, entitlement, org_role, organization_id, created_by, created_at`, + entitlement, orgRole, orgID, createdByJSON)) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok { + switch pqErr.Code { + case "23505": + return nil, ErrAvailabilityExists + case "23503": + return nil, ErrCatalogItemNotFound + } + } + return nil, fmt.Errorf("failed to add availability: %w", err) + } + return a, nil +} + +// Remove deletes one unlock rule by id (scoped to the entitlement for path +// consistency). +func (r *LocalEntitlementAvailabilityRepository) Remove(entitlement, ruleID string) error { + res, err := r.db.Exec( + `DELETE FROM entitlement_availability WHERE id = $1 AND entitlement = $2`, ruleID, entitlement) + if err != nil { + return fmt.Errorf("failed to remove availability: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrAvailabilityNotFound + } + return nil +} + +// LocalSystemEntitlementRepository reads / writes system_entitlements. +type LocalSystemEntitlementRepository struct { + db *sql.DB +} + +func NewLocalSystemEntitlementRepository() *LocalSystemEntitlementRepository { + return &LocalSystemEntitlementRepository{db: database.DB} +} + +const entitlementColumns = ` + id, system_id, entitlement, scope, source, COALESCE(source_ref, ''), + valid_from, valid_until, revoked_at, COALESCE(revoked_source, ''), + COALESCE(pending_ref, ''), pending_since, created_by, purchased_by, variant, renewal_count, created_at, updated_at, + (revoked_at IS NULL AND (valid_until IS NULL OR valid_until > NOW())) AS active` + +func scanEntitlement(scanner interface{ Scan(...interface{}) error }) (*models.SystemEntitlement, error) { + var e models.SystemEntitlement + var validUntil, revokedAt, pendingSince sql.NullTime + var createdBy, purchasedBy, variant []byte + + err := scanner.Scan( + &e.ID, &e.SystemID, &e.Entitlement, &e.Scope, &e.Source, &e.SourceRef, + &e.ValidFrom, &validUntil, &revokedAt, &e.RevokedSource, + &e.PendingRef, &pendingSince, &createdBy, &purchasedBy, &variant, &e.RenewalCount, &e.CreatedAt, &e.UpdatedAt, + &e.Active, + ) + if err != nil { + return nil, err + } + + if validUntil.Valid { + t := validUntil.Time + e.ValidUntil = &t + } + if revokedAt.Valid { + t := revokedAt.Time + e.RevokedAt = &t + } + if pendingSince.Valid { + t := pendingSince.Time + e.PendingSince = &t + } + if len(createdBy) > 0 { + _ = json.Unmarshal(createdBy, &e.CreatedBy) + } + if len(purchasedBy) > 0 { + _ = json.Unmarshal(purchasedBy, &e.PurchasedBy) + } + if len(variant) > 0 { + _ = json.Unmarshal(variant, &e.Variant) + } + + // Grant-level status; callers that know the owning system's lifecycle + // re-derive it with the suspension overlay (models.EntitlementStatus). + e.Status = models.EntitlementStatus(e.Active, e.RevokedAt, false, e.PendingRef) + + return &e, nil +} + +// ListBySystem returns every entitlement row (active or not) for a system. +func (r *LocalSystemEntitlementRepository) ListBySystem(systemID string) ([]*models.SystemEntitlement, error) { + rows, err := r.db.Query( + `SELECT `+entitlementColumns+` + FROM system_entitlements + WHERE system_id = $1 + ORDER BY entitlement, scope`, systemID) + if err != nil { + return nil, fmt.Errorf("failed to list entitlements: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []*models.SystemEntitlement{} + for rows.Next() { + e, err := scanEntitlement(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan entitlement: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + +// Get returns one entitlement row by (system, entitlement id, scope). +func (r *LocalSystemEntitlementRepository) Get(systemID, entitlement, scope string) (*models.SystemEntitlement, error) { + e, err := scanEntitlement(r.db.QueryRow( + `SELECT `+entitlementColumns+` + FROM system_entitlements + WHERE system_id = $1 AND entitlement = $2 AND scope = $3`, systemID, entitlement, scope)) + if err == sql.ErrNoRows { + return nil, ErrEntitlementNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get entitlement: %w", err) + } + return e, nil +} + +// Create grants an entitlement to a system. Duplicate (system, entitlement, +// scope) tuples return ErrEntitlementExists — renewals must Update the +// existing row. purchasedBy carries the buyer snapshot when the caller has +// one (legacy-import backfill with the order email); nil otherwise. +func (r *LocalSystemEntitlementRepository) Create(systemID, entitlement, scope, source, sourceRef string, validFrom, validUntil *time.Time, createdBy, purchasedBy map[string]interface{}) (*models.SystemEntitlement, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + // interface{} nil → SQL NULL: lib/pq encodes a nil []byte as an empty + // string, which jsonb rejects. + var purchasedByJSON interface{} + if purchasedBy != nil { + purchasedByJSON, _ = json.Marshal(purchasedBy) + } + + // validFrom NULL → DB default now(); set for legacy imports to preserve + // the original order date. + e, err := scanEntitlement(r.db.QueryRow( + `INSERT INTO system_entitlements (system_id, entitlement, scope, source, source_ref, valid_from, valid_until, created_by, purchased_by) + VALUES ($1, $2, $3, $4, NULLIF($5, ''), COALESCE($6, now()), $7, $8, $9) + RETURNING `+entitlementColumns, + systemID, entitlement, scope, source, sourceRef, validFrom, validUntil, createdByJSON, purchasedByJSON)) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok { + switch pqErr.Code { + case "23505": + return nil, ErrEntitlementExists + case "23503": + // FK on entitlement_catalog — unknown entitlement id + return nil, ErrCatalogItemNotFound + } + } + return nil, fmt.Errorf("failed to create entitlement: %w", err) + } + return e, nil +} + +// Update applies expiry and/or revocation changes to an existing row. +// setValidUntil distinguishes "leave valid_until alone" (false) from "set it +// to the given value, possibly NULL" (true). revokedSource records who is +// revoking (manual|shop): stamped only on the transition to revoked (an +// idempotent re-revoke keeps the original), cleared on unrevoke. +func (r *LocalSystemEntitlementRepository) Update(systemID, entitlement, scope string, setValidUntil bool, validUntil *time.Time, revoked *bool, revokedSource string) (*models.SystemEntitlement, error) { + e, err := scanEntitlement(r.db.QueryRow( + `UPDATE system_entitlements SET + valid_until = CASE WHEN $4 THEN $5 ELSE valid_until END, + revoked_source = CASE + WHEN $6::boolean IS NULL THEN revoked_source + WHEN $6 THEN CASE WHEN revoked_at IS NULL THEN NULLIF($7, '') ELSE revoked_source END + ELSE NULL + END, + revoked_at = CASE + WHEN $6::boolean IS NULL THEN revoked_at + WHEN $6 THEN COALESCE(revoked_at, NOW()) + ELSE NULL + END, + pending_ref = CASE WHEN $6::boolean IS TRUE THEN NULL ELSE pending_ref END, + pending_since = CASE WHEN $6::boolean IS TRUE THEN NULL ELSE pending_since END, + updated_at = NOW() + WHERE system_id = $1 AND entitlement = $2 AND scope = $3 + RETURNING `+entitlementColumns, + systemID, entitlement, scope, setValidUntil, validUntil, revoked, revokedSource)) + if err == sql.ErrNoRows { + return nil, ErrEntitlementNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to update entitlement: %w", err) + } + return e, nil +} + +// Revoke marks the entitlement as revoked (idempotent, row kept for audit). +// source records who revoked: manual (admin) or shop (deactivate webhook). +func (r *LocalSystemEntitlementRepository) Revoke(systemID, entitlement, scope, source string) (*models.SystemEntitlement, error) { + revoked := true + return r.Update(systemID, entitlement, scope, false, nil, &revoked, source) +} + +// Upsert creates the grant or, when the (system, entitlement, scope) row +// already exists, renews it in place: new expiry, revocation cleared, +// source/source_ref refreshed. This is the shop-webhook semantics — +// activation and renewal are the same idempotent call (safe on retries). +// purchasedBy (the buyer audit snapshot) and variant (the shop tier of the +// order line) replace the stored ones only when provided — an activation +// without them (stamped legacy order) must not wipe what a previous +// purchase recorded. +func (r *LocalSystemEntitlementRepository) Upsert(systemID, entitlement, scope, source, sourceRef string, validUntil *time.Time, createdBy, purchasedBy, variant map[string]interface{}) (*models.SystemEntitlement, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + // interface{} nil → SQL NULL: lib/pq encodes a nil []byte as an empty + // string, which jsonb rejects. + var purchasedByJSON interface{} + if purchasedBy != nil { + purchasedByJSON, _ = json.Marshal(purchasedBy) + } + var variantJSON interface{} + if variant != nil { + variantJSON, _ = json.Marshal(variant) + } + + e, err := scanEntitlement(r.db.QueryRow( + `INSERT INTO system_entitlements (system_id, entitlement, scope, source, source_ref, valid_until, created_by, purchased_by, variant) + VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7, $8, $9) + ON CONFLICT (system_id, entitlement, scope) DO UPDATE SET + valid_until = EXCLUDED.valid_until, + valid_from = CASE WHEN system_entitlements.valid_until = system_entitlements.valid_from + THEN NOW() ELSE system_entitlements.valid_from END, + revoked_at = NULL, + revoked_source = NULL, + pending_ref = NULL, + pending_since = NULL, + source = EXCLUDED.source, + source_ref = COALESCE(EXCLUDED.source_ref, system_entitlements.source_ref), + purchased_by = COALESCE(EXCLUDED.purchased_by, system_entitlements.purchased_by), + variant = COALESCE(EXCLUDED.variant, system_entitlements.variant), + -- a renewal is an activation paid by a DIFFERENT order: webhook + -- retries on the same order never double-count + renewal_count = system_entitlements.renewal_count + + CASE WHEN EXCLUDED.source_ref IS NOT NULL + AND EXCLUDED.source_ref IS DISTINCT FROM system_entitlements.source_ref + THEN 1 ELSE 0 END, + updated_at = NOW() + RETURNING `+entitlementColumns, + systemID, entitlement, scope, source, sourceRef, validUntil, createdByJSON, purchasedByJSON, variantJSON)) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23503" { + return nil, ErrCatalogItemNotFound + } + return nil, fmt.Errorf("failed to upsert entitlement: %w", err) + } + return e, nil +} + +// MarkPending records a shop order placed at checkout and not yet paid. +// Display-only: on a fresh purchase it creates a NON-active stub row +// (valid_until = valid_from → enforcement keeps answering 403); on an +// existing row (renewal, re-buy after revoke/expiry) it only stamps the +// pending marker, leaving validity and revocation untouched. Idempotent. +// purchasedBy and variant are stamped on the fresh stub only: a pending +// renewal must not overwrite who bought — or which tier — the grant the +// customer currently has; activate stamps both when the payment lands. +func (r *LocalSystemEntitlementRepository) MarkPending(systemID, entitlement, scope, ref string, createdBy, purchasedBy, variant map[string]interface{}) (*models.SystemEntitlement, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + // interface{} nil → SQL NULL: lib/pq encodes a nil []byte as an empty + // string, which jsonb rejects. + var purchasedByJSON interface{} + if purchasedBy != nil { + purchasedByJSON, _ = json.Marshal(purchasedBy) + } + var variantJSON interface{} + if variant != nil { + variantJSON, _ = json.Marshal(variant) + } + + e, err := scanEntitlement(r.db.QueryRow( + `INSERT INTO system_entitlements (system_id, entitlement, scope, source, source_ref, valid_from, valid_until, pending_ref, pending_since, created_by, purchased_by, variant) + VALUES ($1, $2, $3, 'shop', $4, NOW(), NOW(), $4, NOW(), $5, $6, $7) + ON CONFLICT (system_id, entitlement, scope) DO UPDATE SET + pending_ref = EXCLUDED.pending_ref, + pending_since = NOW(), + updated_at = NOW() + RETURNING `+entitlementColumns, + systemID, entitlement, scope, ref, createdByJSON, purchasedByJSON, variantJSON)) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23503" { + return nil, ErrCatalogItemNotFound + } + return nil, fmt.Errorf("failed to mark entitlement pending: %w", err) + } + return e, nil +} + +// ClearPending removes the pending marker (order cancelled/failed before +// payment). A never-activated stub (valid_until = valid_from, not revoked) +// is deleted outright so the UI goes back to "not purchased"; in that case +// (nil, nil) is returned. ErrEntitlementNotFound when no row matches. +func (r *LocalSystemEntitlementRepository) ClearPending(systemID, entitlement, scope string) (*models.SystemEntitlement, error) { + res, err := r.db.Exec( + `DELETE FROM system_entitlements + WHERE system_id = $1 AND entitlement = $2 AND scope = $3 + AND pending_ref IS NOT NULL AND revoked_at IS NULL AND valid_until = valid_from`, + systemID, entitlement, scope) + if err != nil { + return nil, fmt.Errorf("failed to clear pending entitlement: %w", err) + } + if n, _ := res.RowsAffected(); n > 0 { + return nil, nil + } + + e, err := scanEntitlement(r.db.QueryRow( + `UPDATE system_entitlements SET pending_ref = NULL, pending_since = NULL, updated_at = NOW() + WHERE system_id = $1 AND entitlement = $2 AND scope = $3 + RETURNING `+entitlementColumns, + systemID, entitlement, scope)) + if err == sql.ErrNoRows { + return nil, ErrEntitlementNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to clear pending entitlement: %w", err) + } + return e, nil +} + +// FindSystemIDByKey resolves a system_key (NETH-...) to the internal system +// id; deleted systems are excluded. +func (r *LocalSystemEntitlementRepository) FindSystemIDByKey(systemKey string) (id, systemType string, err error) { + var sysType sql.NullString + err = r.db.QueryRow( + `SELECT id, type FROM systems WHERE system_key = $1 AND deleted_at IS NULL`, systemKey).Scan(&id, &sysType) + if err == sql.ErrNoRows { + return "", "", ErrEntitlementNotFound + } + if err != nil { + return "", "", fmt.Errorf("failed to resolve system key: %w", err) + } + return id, sysType.String, nil +} + +// GrantsReportFilter narrows the fleet-wide grants report. OrgScope nil = +// no restriction (owner/Super Admin); otherwise only systems whose +// organization_id is in the set are returned (caller's hierarchy). +type GrantsReportFilter struct { + Entitlement string + OrganizationID string + Source string + ActiveOnly bool + ExpiringBefore *time.Time + OrgScope []string +} + +const grantsReportJoin = ` + FROM system_entitlements e + JOIN systems s ON s.id = e.system_id + LEFT JOIN distributors d ON (s.organization_id = d.logto_id) AND d.deleted_at IS NULL + LEFT JOIN resellers r ON (s.organization_id = r.logto_id) AND r.deleted_at IS NULL + LEFT JOIN customers c ON (s.organization_id = c.logto_id) AND c.deleted_at IS NULL` + +func (f *GrantsReportFilter) whereClause(args *[]interface{}) string { + where := "WHERE s.deleted_at IS NULL" + add := func(clause string, v interface{}) { + *args = append(*args, v) + where += fmt.Sprintf(" AND "+clause, len(*args)) + } + if f.Entitlement != "" { + add("e.entitlement = $%d", f.Entitlement) + } + if f.OrganizationID != "" { + add("s.organization_id = $%d", f.OrganizationID) + } + if f.Source != "" { + add("e.source = $%d", f.Source) + } + if f.ExpiringBefore != nil { + add("e.valid_until IS NOT NULL AND e.valid_until <= $%d", *f.ExpiringBefore) + } + if f.ActiveOnly { + where += " AND e.revoked_at IS NULL AND (e.valid_until IS NULL OR e.valid_until > NOW())" + } + if f.OrgScope != nil { + add("s.organization_id = ANY($%d)", pq.Array(f.OrgScope)) + } + return where +} + +// ListGrants returns the grants report (grant + system + org identity), +// newest first, with total count for pagination. +func (r *LocalSystemEntitlementRepository) ListGrants(f GrantsReportFilter, limit, offset int) ([]*models.EntitlementGrantReportRow, int, error) { + args := []interface{}{} + where := f.whereClause(&args) + + var total int + if err := r.db.QueryRow(`SELECT COUNT(*) `+grantsReportJoin+` `+where, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count grants: %w", err) + } + + args = append(args, limit, offset) + rows, err := r.db.Query( + `SELECT e.id, e.system_id, e.entitlement, e.scope, e.source, COALESCE(e.source_ref, ''), + e.valid_from, e.valid_until, e.revoked_at, COALESCE(e.revoked_source, ''), + COALESCE(e.pending_ref, ''), e.pending_since, e.created_by, e.purchased_by, e.variant, e.renewal_count, e.created_at, e.updated_at, + (e.revoked_at IS NULL AND (e.valid_until IS NULL OR e.valid_until > NOW())) AS active, + (s.suspended_at IS NOT NULL) AS system_suspended, + s.name, COALESCE(s.system_key, ''), s.organization_id, + COALESCE(d.name, r.name, c.name, 'Owner') AS organization_name + `+grantsReportJoin+` + `+where+` + ORDER BY e.created_at DESC + LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), + args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to list grants: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []*models.EntitlementGrantReportRow{} + for rows.Next() { + var row models.EntitlementGrantReportRow + var validUntil, revokedAt, pendingSince sql.NullTime + var createdBy, purchasedBy, variant []byte + var systemSuspended bool + err := rows.Scan( + &row.ID, &row.SystemID, &row.Entitlement, &row.Scope, &row.Source, &row.SourceRef, + &row.ValidFrom, &validUntil, &revokedAt, &row.RevokedSource, + &row.PendingRef, &pendingSince, &createdBy, &purchasedBy, &variant, &row.RenewalCount, &row.CreatedAt, &row.UpdatedAt, + &row.Active, &systemSuspended, + &row.SystemName, &row.SystemKey, &row.OrganizationID, &row.OrganizationName, + ) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan grant row: %w", err) + } + if validUntil.Valid { + t := validUntil.Time + row.ValidUntil = &t + } + if revokedAt.Valid { + t := revokedAt.Time + row.RevokedAt = &t + } + if pendingSince.Valid { + t := pendingSince.Time + row.PendingSince = &t + } + if len(createdBy) > 0 { + _ = json.Unmarshal(createdBy, &row.CreatedBy) + } + if len(purchasedBy) > 0 { + _ = json.Unmarshal(purchasedBy, &row.PurchasedBy) + } + if len(variant) > 0 { + _ = json.Unmarshal(variant, &row.Variant) + } + // Deleted systems are filtered out by the WHERE, so the overlay here + // is suspension only. + row.Status = models.EntitlementStatus(row.Active, row.RevokedAt, systemSuspended, row.PendingRef) + out = append(out, &row) + } + return out, total, rows.Err() +} + +// reportStatusExpr derives the grant lifecycle status in SQL with the same +// precedence as models.EntitlementStatus (suspended > active > pending > +// revoked > expired); `active` mirrors the usual validity expression. +const reportStatusExpr = ` + CASE + WHEN (e.revoked_at IS NULL AND (e.valid_until IS NULL OR e.valid_until > NOW())) + AND s.suspended_at IS NOT NULL THEN 'suspended' + WHEN (e.revoked_at IS NULL AND (e.valid_until IS NULL OR e.valid_until > NOW())) THEN 'active' + WHEN e.pending_ref IS NOT NULL THEN 'pending' + WHEN e.revoked_at IS NOT NULL THEN 'revoked' + ELSE 'expired' + END` + +// Report builds the fleet-wide add-on analytics for the owner/Super Admin +// view: lifecycle totals, per-type / per-org / per-tier breakdowns, renewal +// distribution and a 12-month activation trend. Deleted systems excluded. +func (r *LocalSystemEntitlementRepository) Report() (*models.EntitlementReport, error) { + report := &models.EntitlementReport{ + ByEntitlement: []models.EntitlementReportByType{}, + Trend: []models.EntitlementReportTrendRow{}, + } + + statusJoin := `FROM system_entitlements e JOIN systems s ON s.id = e.system_id WHERE s.deleted_at IS NULL` + + // Totals: lifecycle counts, expiry buckets, coverage (with the org-type + // breakdown of who is buying) and renewals in one pass. + err := r.db.QueryRow(` + SELECT COUNT(*), + COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'active'), + COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'expired'), + COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'revoked'), + COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'pending'), + COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'suspended'), + COUNT(*) FILTER (WHERE e.revoked_at IS NULL AND e.valid_until IS NULL), + COUNT(*) FILTER (WHERE e.revoked_at IS NULL AND e.valid_until > NOW() AND e.valid_until <= NOW() + interval '30 days'), + COUNT(*) FILTER (WHERE e.revoked_at IS NULL AND e.valid_until > NOW() AND e.valid_until <= NOW() + interval '60 days'), + COUNT(*) FILTER (WHERE e.revoked_at IS NULL AND e.valid_until > NOW() AND e.valid_until <= NOW() + interval '90 days'), + COUNT(DISTINCT e.system_id), + COUNT(DISTINCT s.organization_id), + COUNT(DISTINCT e.system_id) FILTER (WHERE d.logto_id IS NOT NULL), + COUNT(DISTINCT e.system_id) FILTER (WHERE rs.logto_id IS NOT NULL), + COUNT(DISTINCT e.system_id) FILTER (WHERE c.logto_id IS NOT NULL), + COUNT(DISTINCT e.system_id) FILTER (WHERE d.logto_id IS NULL AND rs.logto_id IS NULL AND c.logto_id IS NULL), + COALESCE(SUM(e.renewal_count), 0) + FROM system_entitlements e + JOIN systems s ON s.id = e.system_id + LEFT JOIN distributors d ON s.organization_id = d.logto_id AND d.deleted_at IS NULL + LEFT JOIN resellers rs ON s.organization_id = rs.logto_id AND rs.deleted_at IS NULL + LEFT JOIN customers c ON s.organization_id = c.logto_id AND c.deleted_at IS NULL + WHERE s.deleted_at IS NULL`, + ).Scan( + &report.Totals.Total, &report.Totals.Active, &report.Totals.Expired, + &report.Totals.Revoked, &report.Totals.Pending, &report.Totals.Suspended, + &report.Totals.Perpetual, + &report.Totals.ExpiringIn30d, &report.Totals.ExpiringIn60d, &report.Totals.ExpiringIn90d, + &report.Totals.Systems, &report.Totals.Organizations, + &report.Totals.DistributorSystems, &report.Totals.ResellerSystems, &report.Totals.CustomerSystems, + &report.Totals.OwnerSystems, + &report.Totals.TotalRenewals, + ) + if err != nil { + return nil, fmt.Errorf("failed to compute report totals: %w", err) + } + + // Renewal distribution. + err = r.db.QueryRow(` + SELECT COUNT(*) FILTER (WHERE e.renewal_count = 0), + COUNT(*) FILTER (WHERE e.renewal_count = 1), + COUNT(*) FILTER (WHERE e.renewal_count = 2), + COUNT(*) FILTER (WHERE e.renewal_count >= 3) + `+statusJoin, + ).Scan(&report.Renewals.Never, &report.Renewals.Once, &report.Renewals.Twice, &report.Renewals.ThreePlus) + if err != nil { + return nil, fmt.Errorf("failed to compute renewal distribution: %w", err) + } + + // Per add-on type. + rows, err := r.db.Query(` + SELECT e.entitlement, COALESCE(cat.display_name, e.entitlement), + COUNT(*) FILTER (WHERE ` + reportStatusExpr + ` = 'active'), + COUNT(*) FILTER (WHERE ` + reportStatusExpr + ` = 'expired'), + COUNT(*) FILTER (WHERE ` + reportStatusExpr + ` = 'revoked'), + COUNT(*) FILTER (WHERE ` + reportStatusExpr + ` = 'pending'), + COUNT(*) FILTER (WHERE ` + reportStatusExpr + ` = 'suspended'), + COUNT(*) + FROM system_entitlements e + JOIN systems s ON s.id = e.system_id + LEFT JOIN entitlement_catalog cat ON cat.id = e.entitlement + WHERE s.deleted_at IS NULL + GROUP BY e.entitlement, cat.display_name + ORDER BY COUNT(*) DESC, e.entitlement`) + if err != nil { + return nil, fmt.Errorf("failed to compute per-type report: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var row models.EntitlementReportByType + if err := rows.Scan(&row.Entitlement, &row.DisplayName, &row.Active, &row.Expired, &row.Revoked, &row.Pending, &row.Suspended, &row.Total); err != nil { + return nil, fmt.Errorf("failed to scan per-type row: %w", err) + } + report.ByEntitlement = append(report.ByEntitlement, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Activation trend: grants created per month, last 12 months. + trendRows, err := r.db.Query(` + SELECT to_char(date_trunc('month', e.created_at), 'YYYY-MM'), COUNT(*) + ` + statusJoin + ` AND e.created_at >= date_trunc('month', NOW()) - interval '11 months' + GROUP BY 1 ORDER BY 1`) + if err != nil { + return nil, fmt.Errorf("failed to compute activation trend: %w", err) + } + defer func() { _ = trendRows.Close() }() + for trendRows.Next() { + var row models.EntitlementReportTrendRow + if err := trendRows.Scan(&row.Month, &row.Activations); err != nil { + return nil, fmt.Errorf("failed to scan trend row: %w", err) + } + report.Trend = append(report.Trend, row) + } + if err := trendRows.Err(); err != nil { + return nil, err + } + + return report, nil +} + +// ReportOrganizations is the paginated + searchable per-organization slice +// of the add-on report (orgs can be hundreds on the real fleet): grants +// grouped by the systems' owning organization, most active first. search +// filters by organization name. +func (r *LocalSystemEntitlementRepository) ReportOrganizations(search string, limit, offset int) ([]models.EntitlementReportByOrg, int, error) { + base := ` + FROM system_entitlements e + JOIN systems s ON s.id = e.system_id + LEFT JOIN distributors d ON s.organization_id = d.logto_id AND d.deleted_at IS NULL + LEFT JOIN resellers rs ON s.organization_id = rs.logto_id AND rs.deleted_at IS NULL + LEFT JOIN customers c ON s.organization_id = c.logto_id AND c.deleted_at IS NULL + WHERE s.deleted_at IS NULL + AND ($1 = '' OR COALESCE(d.name, rs.name, c.name, 'Owner') ILIKE '%' || $1 || '%') + GROUP BY s.organization_id, d.logto_id, rs.logto_id, c.logto_id, d.name, rs.name, c.name` + + var total int + if err := r.db.QueryRow(`SELECT COUNT(*) FROM (SELECT 1 `+base+`) g`, search).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count report organizations: %w", err) + } + + rows, err := r.db.Query(` + SELECT s.organization_id, + COALESCE(d.name, rs.name, c.name, 'Owner'), + COALESCE( + CASE WHEN d.logto_id IS NOT NULL THEN 'distributor' + WHEN rs.logto_id IS NOT NULL THEN 'reseller' + WHEN c.logto_id IS NOT NULL THEN 'customer' + END, 'owner'), + COUNT(DISTINCT e.system_id), + COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'active'), + COUNT(*) + `+base+` + ORDER BY COUNT(*) FILTER (WHERE `+reportStatusExpr+` = 'active') DESC, COUNT(*) DESC + LIMIT $2 OFFSET $3`, search, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to compute per-org report: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []models.EntitlementReportByOrg{} + for rows.Next() { + var row models.EntitlementReportByOrg + if err := rows.Scan(&row.OrganizationID, &row.OrganizationName, &row.OrgType, &row.Systems, &row.Active, &row.Total); err != nil { + return nil, 0, fmt.Errorf("failed to scan per-org row: %w", err) + } + out = append(out, row) + } + return out, total, rows.Err() +} + +// ReportVariants is the paginated + searchable per-tier slice of the add-on +// report (variable products only). search filters by add-on id or tier label. +func (r *LocalSystemEntitlementRepository) ReportVariants(search string, limit, offset int) ([]models.EntitlementReportByVariant, int, error) { + where := ` + FROM system_entitlements e + JOIN systems s ON s.id = e.system_id + WHERE s.deleted_at IS NULL AND e.variant->>'label' IS NOT NULL + AND ($1 = '' OR e.entitlement ILIKE '%' || $1 || '%' OR e.variant->>'label' ILIKE '%' || $1 || '%') + GROUP BY e.entitlement, e.variant->>'label'` + + var total int + if err := r.db.QueryRow(`SELECT COUNT(*) FROM (SELECT 1 `+where+`) g`, search).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count report tiers: %w", err) + } + + rows, err := r.db.Query(` + SELECT e.entitlement, e.variant->>'label', COUNT(*) + `+where+` + ORDER BY e.entitlement, COUNT(*) DESC + LIMIT $2 OFFSET $3`, search, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to compute per-variant report: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []models.EntitlementReportByVariant{} + for rows.Next() { + var row models.EntitlementReportByVariant + if err := rows.Scan(&row.Entitlement, &row.Label, &row.Count); err != nil { + return nil, 0, fmt.Errorf("failed to scan per-variant row: %w", err) + } + out = append(out, row) + } + return out, total, rows.Err() +} + +// Stats aggregates ACTIVE grants per entitlement per organization, within +// the caller's scope (nil = everything). +func (r *LocalSystemEntitlementRepository) Stats(orgScope []string) ([]*models.EntitlementStatsRow, error) { + f := GrantsReportFilter{ActiveOnly: true, OrgScope: orgScope} + args := []interface{}{} + where := f.whereClause(&args) + + rows, err := r.db.Query( + `SELECT e.entitlement, s.organization_id, + COALESCE(d.name, r.name, c.name, 'Owner') AS organization_name, + COUNT(*) AS active_grants + `+grantsReportJoin+` + `+where+` + GROUP BY e.entitlement, s.organization_id, organization_name + ORDER BY e.entitlement, active_grants DESC`, + args...) + if err != nil { + return nil, fmt.Errorf("failed to compute entitlement stats: %w", err) + } + defer func() { _ = rows.Close() }() + + out := []*models.EntitlementStatsRow{} + for rows.Next() { + var row models.EntitlementStatsRow + if err := rows.Scan(&row.Entitlement, &row.OrganizationID, &row.OrganizationName, &row.ActiveGrants); err != nil { + return nil, fmt.Errorf("failed to scan stats row: %w", err) + } + out = append(out, &row) + } + return out, rows.Err() +} diff --git a/backend/entities/local_users.go b/backend/entities/local_users.go index 1ad6ebab4..c9a616759 100644 --- a/backend/entities/local_users.go +++ b/backend/entities/local_users.go @@ -183,6 +183,74 @@ func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) { return user, nil } +// GetByEmail retrieves a user by email (case-insensitive). Emails are not +// guaranteed unique across organizations: prefer a non-suspended account, +// then the most recent one. +func (r *LocalUserRepository) GetByEmail(email string) (*models.LocalUser, error) { + query := ` + SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data, u.created_by, + u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.deleted_at, u.suspended_at, u.suspended_by_org_id, + uo.name as organization_name, + COALESCE(uo.db_id, '') as organization_local_id, + COALESCE(uo.org_type, 'owner') as organization_type + FROM users u + LEFT JOIN unified_organizations uo ON u.organization_id = uo.logto_id + WHERE LOWER(u.email) = LOWER($1) AND u.deleted_at IS NULL + ORDER BY (u.suspended_at IS NULL) DESC, u.created_at DESC + LIMIT 1 + ` + + user := &models.LocalUser{} + var customDataJSON []byte + var userRoleIDsJSON []byte + var createdByJSON []byte + + err := r.db.QueryRow(query, email).Scan( + &user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name, &user.Phone, + &user.OrganizationID, &userRoleIDsJSON, &customDataJSON, &createdByJSON, + &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.DeletedAt, &user.SuspendedAt, &user.SuspendedByOrgID, + &user.OrganizationName, &user.OrganizationLocalID, &user.OrganizationType, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + // Parse user_role_ids JSON + if len(userRoleIDsJSON) > 0 { + if err := json.Unmarshal(userRoleIDsJSON, &user.UserRoleIDs); err != nil { + user.UserRoleIDs = []string{} + } + } else { + user.UserRoleIDs = []string{} + } + + // Parse custom_data JSON + if len(customDataJSON) > 0 { + if err := json.Unmarshal(customDataJSON, &user.CustomData); err != nil { + user.CustomData = make(map[string]interface{}) + } + } else { + user.CustomData = make(map[string]interface{}) + } + + // Parse created_by JSON (creator snapshot; may be NULL on pre-backfill rows) + if len(createdByJSON) > 0 { + var creator models.OrgCreator + if err := json.Unmarshal(createdByJSON, &creator); err == nil { + user.CreatedBy = &creator + } + } + + // Enrich with organization and role data + _ = r.enrichUserWithRelations(user) // Relations are nice-to-have, don't fail request on error + + return user, nil +} + // GetByLogtoID retrieves a user by Logto ID from local database func (r *LocalUserRepository) GetByLogtoID(logtoID string) (*models.LocalUser, error) { query := ` diff --git a/backend/main.go b/backend/main.go index 42f92258e..6203bfb11 100644 --- a/backend/main.go +++ b/backend/main.go @@ -247,6 +247,41 @@ func main() { systemsGroup.PATCH("/:id/suspend", methods.SuspendSystem) // Suspend system (manage:systems required) systemsGroup.PATCH("/:id/reactivate", methods.ReactivateSystem) // Reactivate suspended system (manage:systems required) + // Entitlements (granular add-on licensing; writes need the dedicated manage:entitlements permission) + systemsGroup.GET("/:id/entitlements", methods.ListSystemEntitlements) + systemsGroup.POST("/:id/entitlements", methods.CreateSystemEntitlement) // owner org / Super Admin only (handler-gated) + systemsGroup.PUT("/:id/entitlements/:entitlement", methods.UpdateSystemEntitlement) // owner org / Super Admin only (handler-gated) + systemsGroup.DELETE("/:id/entitlements/:entitlement", methods.DeleteSystemEntitlement) // owner org / Super Admin only (handler-gated) + } + + // Entitlement catalog (DB-driven add-on types; writes need manage:entitlements — the licensing back-office duty) + entitlementsGroup := customAuthWithAudit.Group("/entitlements", middleware.RequireResourcePermission("entitlements")) + { + entitlementsGroup.GET("/catalog", methods.ListEntitlementCatalog) + entitlementsGroup.POST("/catalog", methods.CreateEntitlementCatalogItem) // owner org / Super Admin only (handler-gated) + entitlementsGroup.PUT("/catalog/:id", methods.UpdateEntitlementCatalogItem) // owner org / Super Admin only (handler-gated) + entitlementsGroup.DELETE("/catalog/:id", methods.DeleteEntitlementCatalogItem) // owner org / Super Admin only (handler-gated) + + // Commercial availability (who may buy/self-activate a type) + entitlementsGroup.GET("/catalog/:id/availability", methods.ListEntitlementAvailability) + entitlementsGroup.POST("/catalog/:id/availability", methods.CreateEntitlementAvailability) // owner org / Super Admin only (handler-gated) + entitlementsGroup.DELETE("/catalog/:id/availability/:rule_id", methods.DeleteEntitlementAvailability) // owner org / Super Admin only (handler-gated) + + // What the caller's org may buy (drives my UI / shop) + entitlementsGroup.GET("/available", methods.ListAvailableEntitlements) + + // Reporting: buyers see their hierarchy (expirations/renewals), owner/SA the fleet + entitlementsGroup.GET("/grants", methods.GetEntitlementGrants) + entitlementsGroup.GET("/stats", methods.GetEntitlementStats) + entitlementsGroup.GET("/report", methods.GetEntitlementReport) // owner org / Super Admin only (handler-gated) + entitlementsGroup.GET("/report/organizations", methods.GetEntitlementReportOrganizations) // owner org / Super Admin only (handler-gated) + entitlementsGroup.GET("/report/tiers", methods.GetEntitlementReportTiers) // owner org / Super Admin only (handler-gated) + + // Shop webhook: activation/renewal + deactivation by system_key (owner API key) + entitlementsGroup.POST("/activate", middleware.RequirePermission("manage:entitlements"), methods.ActivateEntitlement) + entitlementsGroup.POST("/deactivate", middleware.RequirePermission("manage:entitlements"), methods.DeactivateEntitlement) + entitlementsGroup.POST("/pending", middleware.RequirePermission("manage:entitlements"), methods.PendingEntitlement) // checkout: order placed, payment not confirmed yet + // Systems totals and trend endpoints (read:systems required) systemsGroup.GET("/totals", methods.GetSystemsTotals) systemsGroup.GET("/trend", methods.GetSystemsTrend) diff --git a/backend/methods/entitlements.go b/backend/methods/entitlements.go new file mode 100644 index 000000000..d2ac72ae6 --- /dev/null +++ b/backend/methods/entitlements.go @@ -0,0 +1,1111 @@ +/* +Copyright (C) 2026 Nethesis S.r.l. +SPDX-License-Identifier: AGPL-3.0-or-later +*/ + +package methods + +import ( + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/nethesis/my/backend/entities" + "github.com/nethesis/my/backend/helpers" + "github.com/nethesis/my/backend/logger" + "github.com/nethesis/my/backend/models" + "github.com/nethesis/my/backend/response" + "github.com/nethesis/my/backend/services/local" +) + +// Catalog ids are kebab-case (convention: nsec-, ns8-, +// -; the ng-* ids are the legacy wire ids). +var entitlementIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,98}$`) + +// isSystemBlocked reports whether the system cannot use its entitlements at +// all: suspended or deleted (directly, or via the org cascade). Grants stay +// untouched but are reported with status=suspended. +func isSystemBlocked(system *models.System) bool { + return system.Status == "suspended" || system.Status == "deleted" +} + +// isEntitlementAdmin returns true for the ADMINISTRATIVE surface — catalog +// management, manual grants via API, fleet-wide visibility: only the owner +// organization or a Super Admin user (Nethesis). +func isEntitlementAdmin(u *models.User) bool { + return strings.EqualFold(u.OrgRole, "owner") || slices.Contains(u.UserRoles, "Super Admin") +} + +// canTransactEntitlements returns true for the TRANSACTIONAL surface — buy +// on the shop / cancel a subscription (activate/deactivate): the dedicated +// manage:entitlements permission, held by the Backoffice and Super Admin +// user roles. +func canTransactEntitlements(u *models.User) bool { + return slices.Contains(u.UserPermissions, "manage:entitlements") || + slices.Contains(u.OrgPermissions, "manage:entitlements") +} + +// entitlementAccessCheck resolves the system with the caller's hierarchy +// scope (same validation as GET /systems/:id) and, for writes, restricts to +// entitlement managers (manage:entitlements). Managers bypass the hierarchy +// (they operate on any system): Nethesis licensing back-office staff live +// under the Nethesis Italia distributor but must manage licences fleet-wide. +func entitlementAccessCheck(c *gin.Context, write bool) (system *models.System, user *models.User, ok bool) { + systemID := c.Param("id") + if systemID == "" { + c.JSON(http.StatusBadRequest, response.BadRequest("system ID required", nil)) + return nil, nil, false + } + + u, found := helpers.GetUserFromContext(c) + if !found { + return nil, nil, false + } + user = u + + isAdmin := isEntitlementAdmin(u) + + if write && !isAdmin { + c.JSON(http.StatusForbidden, response.Forbidden("only the owner organization or a Super Admin can manage grants directly", nil)) + return nil, nil, false + } + + effectiveOrgRole := u.OrgRole + if isAdmin { + effectiveOrgRole = "owner" + } + + systemsService := local.NewSystemsService() + sys, err := systemsService.GetSystem(systemID, effectiveOrgRole, u.OrganizationID) + if helpers.HandleAccessError(c, err, "system", systemID) { + return nil, nil, false + } + + return sys, user, true +} + +// systemTypeMismatch returns true when the catalog item is restricted to a +// system type and the system's known type differs (unknown type = allowed: +// the type is only learned from the first inventory). +func systemTypeMismatch(item *models.EntitlementCatalogItem, systemType string) bool { + return item.SystemType != "" && systemType != "" && systemType != item.SystemType +} + +// resolvePurchaser builds the purchased_by audit snapshot from the order's +// customer email sent by the shop webhook (server-to-server, trusted). The +// email is resolved to a my user and snapshotted in full — identity, org and +// roles AT PURCHASE TIME, robust to later renames/moves like the other +// created_by/on_behalf_of snapshots. An address matching no my user is kept +// raw ({email} only); no email at all (stamped legacy order) → nil. +func resolvePurchaser(buyerEmail string) map[string]interface{} { + if buyerEmail == "" { + return nil + } + + u, err := entities.NewLocalUserRepository().GetByEmail(buyerEmail) + if err != nil { + return map[string]interface{}{"email": buyerEmail} + } + + snap := map[string]interface{}{ + "name": u.Name, + "email": u.Email, + } + if u.LogtoID != nil { + snap["logto_id"] = *u.LogtoID + } + if u.Organization != nil { + snap["organization_id"] = u.Organization.LogtoID + snap["organization_name"] = u.Organization.Name + snap["org_role"] = u.Organization.Type + } + roles := make([]string, 0, len(u.Roles)) + for _, role := range u.Roles { + if role.Name != "" { + roles = append(roles, role.Name) + } + } + if len(roles) > 0 { + snap["user_roles"] = roles + } + return snap +} + +// redactPurchaser strips the buyer identity when the buyer's organization is +// outside the viewer's hierarchy (orgScope nil = no restriction, owner/Super +// Admin): a reseller must not learn who sits above them — the UI renders a +// generic "purchased by another organization" from the bare marker. Raw +// email-only snapshots have no organization and are admin-only too. +func redactPurchaser(e *models.SystemEntitlement, orgScope []string) { + if e.PurchasedBy == nil || orgScope == nil { + return + } + orgID, _ := e.PurchasedBy["organization_id"].(string) + if orgID == "" || !slices.Contains(orgScope, orgID) { + e.PurchasedBy = map[string]interface{}{"out_of_scope": true} + } +} + +// =========================================== +// ENTITLEMENT CATALOG +// =========================================== + +// ListEntitlementCatalog handles GET /api/entitlements/catalog +func ListEntitlementCatalog(c *gin.Context) { + repo := entities.NewLocalEntitlementCatalogRepository() + items, err := repo.List() + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to list entitlement catalog") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to list entitlement catalog", nil)) + return + } + c.JSON(http.StatusOK, response.OK("entitlement catalog retrieved successfully", gin.H{"catalog": items})) +} + +// catalogWriteGate rejects callers that are not entitlement admins (owner +// org or Super Admin): the catalog and its availability rules are Nethesis +// product management. +func catalogWriteGate(c *gin.Context) (*models.User, bool) { + u, found := helpers.GetUserFromContext(c) + if !found { + return nil, false + } + if !isEntitlementAdmin(u) { + c.JSON(http.StatusForbidden, response.Forbidden("only the owner organization or a Super Admin can manage the entitlement catalog", nil)) + return nil, false + } + return u, true +} + +// transactGate rejects callers without the manage:entitlements permission +// (buy/cancel surface: Backoffice, Super Admin, shop owner key). +func transactGate(c *gin.Context) (*models.User, bool) { + u, found := helpers.GetUserFromContext(c) + if !found { + return nil, false + } + if !canTransactEntitlements(u) { + c.JSON(http.StatusForbidden, response.Forbidden("manage:entitlements permission required", nil)) + return nil, false + } + return u, true +} + +// CreateEntitlementCatalogItem handles POST /api/entitlements/catalog +func CreateEntitlementCatalogItem(c *gin.Context) { + if _, ok := catalogWriteGate(c); !ok { + return + } + + var req models.CreateEntitlementCatalogRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + if !entitlementIDPattern.MatchString(req.ID) { + c.JSON(http.StatusBadRequest, response.BadRequest("invalid entitlement id: lowercase kebab-case required (e.g. nsec-service, ns8-app, app-module)", nil)) + return + } + if req.LegacyAlias != "" && !entitlementIDPattern.MatchString(req.LegacyAlias) { + c.JSON(http.StatusBadRequest, response.BadRequest("invalid legacy_alias: lowercase kebab-case required", nil)) + return + } + + switch req.Kind { + case "": + req.Kind = models.EntitlementKindService + case models.EntitlementKindService, models.EntitlementKindModule: + default: + c.JSON(http.StatusBadRequest, response.BadRequest("kind must be service or module", nil)) + return + } + switch req.SystemType { + case "", "nsec", "ns8": + default: + c.JSON(http.StatusBadRequest, response.BadRequest("system_type must be nsec, ns8 or empty", nil)) + return + } + + repo := entities.NewLocalEntitlementCatalogRepository() + item, err := repo.Create(&req) + if err == entities.ErrCatalogItemExists { + c.JSON(http.StatusConflict, response.Conflict("catalog item already exists", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Str("catalog_id", req.ID).Msg("Failed to create catalog item") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to create catalog item", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "create_catalog_item"). + Str("catalog_id", req.ID). + Bool("scoped", req.Scoped). + Msg("Entitlement catalog item created") + + c.JSON(http.StatusCreated, response.Created("catalog item created successfully", item)) +} + +// UpdateEntitlementCatalogItem handles PUT /api/entitlements/catalog/:id +func UpdateEntitlementCatalogItem(c *gin.Context) { + if _, ok := catalogWriteGate(c); !ok { + return + } + + var req models.UpdateEntitlementCatalogRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + repo := entities.NewLocalEntitlementCatalogRepository() + item, err := repo.Update(c.Param("id"), req.DisplayName, req.Description) + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusNotFound, response.NotFound("catalog item not found", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Str("catalog_id", c.Param("id")).Msg("Failed to update catalog item") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to update catalog item", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("catalog item updated successfully", item)) +} + +// DeleteEntitlementCatalogItem handles DELETE /api/entitlements/catalog/:id +// Refused while grants reference the item. +func DeleteEntitlementCatalogItem(c *gin.Context) { + if _, ok := catalogWriteGate(c); !ok { + return + } + + repo := entities.NewLocalEntitlementCatalogRepository() + err := repo.Delete(c.Param("id")) + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusNotFound, response.NotFound("catalog item not found", nil)) + return + } + if err == entities.ErrCatalogItemInUse { + c.JSON(http.StatusConflict, response.Conflict("catalog item is referenced by existing grants", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Str("catalog_id", c.Param("id")).Msg("Failed to delete catalog item") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to delete catalog item", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("catalog item deleted successfully", nil)) +} + +// =========================================== +// AVAILABILITY (commercial unlocks) +// =========================================== + +// ListEntitlementAvailability handles GET /api/entitlements/catalog/:id/availability +func ListEntitlementAvailability(c *gin.Context) { + catalogRepo := entities.NewLocalEntitlementCatalogRepository() + if _, err := catalogRepo.Get(c.Param("id")); err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusNotFound, response.NotFound("catalog item not found", nil)) + return + } + + repo := entities.NewLocalEntitlementAvailabilityRepository() + rules, err := repo.ListByEntitlement(c.Param("id")) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to list availability") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to list availability", nil)) + return + } + c.JSON(http.StatusOK, response.OK("availability retrieved successfully", gin.H{"availability": rules})) +} + +// CreateEntitlementAvailability handles POST /api/entitlements/catalog/:id/availability +// Unlocks the catalog item for a hierarchy role OR one organization. +func CreateEntitlementAvailability(c *gin.Context) { + user, ok := catalogWriteGate(c) + if !ok { + return + } + + var req models.CreateEntitlementAvailabilityRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + role := strings.ToLower(req.OrgRole) + if (role == "") == (req.OrganizationID == "") { + c.JSON(http.StatusBadRequest, response.BadRequest("set exactly one of org_role or organization_id", nil)) + return + } + if role != "" && role != "distributor" && role != "reseller" && role != "customer" { + c.JSON(http.StatusBadRequest, response.BadRequest("org_role must be distributor, reseller or customer", nil)) + return + } + + createdBy := map[string]interface{}{ + "user_id": user.ID, + "user_name": user.Name, + "organization_id": user.OrganizationID, + "organization_name": user.OrganizationName, + } + + repo := entities.NewLocalEntitlementAvailabilityRepository() + rule, err := repo.Add(c.Param("id"), role, req.OrganizationID, createdBy) + if err == entities.ErrAvailabilityExists { + c.JSON(http.StatusConflict, response.Conflict("availability rule already exists", nil)) + return + } + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusNotFound, response.NotFound("catalog item not found", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to add availability") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to add availability", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "create_availability"). + Str("catalog_id", c.Param("id")). + Str("org_role", role). + Str("organization_id", req.OrganizationID). + Msg("Entitlement availability rule created") + + c.JSON(http.StatusCreated, response.Created("availability rule created successfully", rule)) +} + +// DeleteEntitlementAvailability handles DELETE /api/entitlements/catalog/:id/availability/:rule_id +func DeleteEntitlementAvailability(c *gin.Context) { + if _, ok := catalogWriteGate(c); !ok { + return + } + + repo := entities.NewLocalEntitlementAvailabilityRepository() + err := repo.Remove(c.Param("id"), c.Param("rule_id")) + if err == entities.ErrAvailabilityNotFound { + c.JSON(http.StatusNotFound, response.NotFound("availability rule not found", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to remove availability") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to remove availability", nil)) + return + } + c.JSON(http.StatusOK, response.OK("availability rule removed successfully", nil)) +} + +// ListAvailableEntitlements handles GET /api/entitlements/available — the +// catalog items the CALLER's organization may buy/self-activate (drives the +// my UI and, in fase 3, the shop). Owner and Super Admin see the whole +// catalog (they can grant anything manually anyway). +func ListAvailableEntitlements(c *gin.Context) { + u, found := helpers.GetUserFromContext(c) + if !found { + return + } + + if isEntitlementAdmin(u) { + repo := entities.NewLocalEntitlementCatalogRepository() + items, err := repo.List() + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to list catalog") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to list available entitlements", nil)) + return + } + c.JSON(http.StatusOK, response.OK("available entitlements retrieved successfully", gin.H{"available": items})) + return + } + + repo := entities.NewLocalEntitlementAvailabilityRepository() + items, err := repo.ListAvailableFor(u.OrgRole, u.OrganizationID) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to list available entitlements") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to list available entitlements", nil)) + return + } + c.JSON(http.StatusOK, response.OK("available entitlements retrieved successfully", gin.H{"available": items})) +} + +// =========================================== +// SHOP ACTIVATION (webhook-facing) +// =========================================== + +// ActivateEntitlement handles POST /api/entitlements/activate — the shop +// webhook calls it after a purchase or subscription renewal with an owner +// API key. Addressed by system_key; idempotent (existing grant renewed in +// place, safe on webhook retries). +func ActivateEntitlement(c *gin.Context) { + user, ok := transactGate(c) + if !ok { + return + } + + var req models.ActivateEntitlementRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + catalogRepo := entities.NewLocalEntitlementCatalogRepository() + item, err := catalogRepo.Resolve(req.Entitlement) + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusBadRequest, response.BadRequest("unknown entitlement", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to resolve entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve entitlement", nil)) + return + } + if req.Scope != "" && !item.Scoped { + c.JSON(http.StatusBadRequest, response.BadRequest("this entitlement does not support per-application scope", nil)) + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + systemID, systemType, err := repo.FindSystemIDByKey(req.SystemKey) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("system not found for this key", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to resolve system key") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system key", nil)) + return + } + if systemTypeMismatch(item, systemType) { + c.JSON(http.StatusBadRequest, response.BadRequest("this entitlement applies to "+item.SystemType+" systems only", nil)) + return + } + + createdBy := map[string]interface{}{ + "user_id": user.ID, + "user_name": user.Name, + "organization_id": user.OrganizationID, + "organization_name": user.OrganizationName, + "channel": "shop", + } + + grant, err := repo.Upsert(systemID, item.ID, req.Scope, models.EntitlementSourceShop, req.SourceRef, req.ValidUntil, createdBy, resolvePurchaser(req.BuyerEmail), req.Variant) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Msg("Failed to activate entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to activate entitlement", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "activate_entitlement"). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Str("scope", req.Scope). + Str("source_ref", req.SourceRef). + Msg("Entitlement activated via shop") + + c.JSON(http.StatusOK, response.OK("entitlement activated successfully", grant)) +} + +// DeactivateEntitlement handles POST /api/entitlements/deactivate — the shop +// webhook calls it when a subscription is cancelled or expires. Idempotent. +func DeactivateEntitlement(c *gin.Context) { + if _, ok := transactGate(c); !ok { + return + } + + var req models.DeactivateEntitlementRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + catalogRepo := entities.NewLocalEntitlementCatalogRepository() + item, err := catalogRepo.Resolve(req.Entitlement) + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusBadRequest, response.BadRequest("unknown entitlement", nil)) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve entitlement", nil)) + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + systemID, _, err := repo.FindSystemIDByKey(req.SystemKey) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("system not found for this key", nil)) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system key", nil)) + return + } + + existing, err := repo.Get(systemID, item.ID, req.Scope) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("entitlement not found for this system", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to read entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to deactivate entitlement", nil)) + return + } + + // Reference matching (when the shop sends one): a cancelled UNPAID order + // only clears its own pending marker, and an order that neither created + // the grant nor is pending on it must not revoke what another order paid + // for. + if req.SourceRef != "" { + if existing.PendingRef == req.SourceRef { + grant, err := repo.ClearPending(systemID, item.ID, req.Scope) + if err != nil && err != entities.ErrEntitlementNotFound { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to clear pending entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to deactivate entitlement", nil)) + return + } + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "clear_pending_entitlement"). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Str("scope", req.Scope). + Str("source_ref", req.SourceRef). + Msg("Pending entitlement activation cleared (order cancelled before payment)") + c.JSON(http.StatusOK, response.OK("pending activation cleared", grant)) + return + } + if existing.SourceRef != req.SourceRef { + logger.RequestLogger(c, "entitlements").Warn(). + Str("operation", "deactivate_entitlement"). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Str("scope", req.Scope). + Str("source_ref", req.SourceRef). + Str("grant_source_ref", existing.SourceRef). + Msg("Deactivate reference does not match the grant; leaving it untouched") + c.JSON(http.StatusOK, response.OK("reference does not match the current grant, nothing deactivated", existing)) + return + } + } + + grant, err := repo.Revoke(systemID, item.ID, req.Scope, models.EntitlementSourceShop) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("entitlement not found for this system", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to deactivate entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to deactivate entitlement", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "deactivate_entitlement"). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Str("scope", req.Scope). + Str("source_ref", req.SourceRef). + Msg("Entitlement deactivated via shop") + + c.JSON(http.StatusOK, response.OK("entitlement deactivated successfully", grant)) +} + +// PendingEntitlement handles POST /api/entitlements/pending — the shop calls +// it at checkout, when the order exists but the payment (bank transfer/RiBa) +// hasn't been confirmed yet. Display-only: the UI shows "pending" instead of +// offering another purchase; enforcement is not affected. Idempotent; the +// later activate (order completed) or deactivate (order cancelled) resolves +// the marker. +func PendingEntitlement(c *gin.Context) { + user, ok := transactGate(c) + if !ok { + return + } + + var req models.PendingEntitlementRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + catalogRepo := entities.NewLocalEntitlementCatalogRepository() + item, err := catalogRepo.Resolve(req.Entitlement) + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusBadRequest, response.BadRequest("unknown entitlement", nil)) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve entitlement", nil)) + return + } + if req.Scope != "" && !item.Scoped { + c.JSON(http.StatusBadRequest, response.BadRequest("this entitlement does not support per-application scope", nil)) + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + systemID, systemType, err := repo.FindSystemIDByKey(req.SystemKey) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("system not found for this key", nil)) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system key", nil)) + return + } + if systemTypeMismatch(item, systemType) { + c.JSON(http.StatusBadRequest, response.BadRequest("this entitlement applies to "+item.SystemType+" systems only", nil)) + return + } + + createdBy := map[string]interface{}{ + "user_id": user.ID, + "user_name": user.Name, + "organization_id": user.OrganizationID, + "organization_name": user.OrganizationName, + "channel": "shop", + } + + grant, err := repo.MarkPending(systemID, item.ID, req.Scope, req.SourceRef, createdBy, resolvePurchaser(req.BuyerEmail), req.Variant) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Msg("Failed to mark entitlement pending") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to mark entitlement pending", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "pending_entitlement"). + Str("system_key", req.SystemKey). + Str("entitlement", item.ID). + Str("scope", req.Scope). + Str("source_ref", req.SourceRef). + Msg("Entitlement activation marked pending (order awaiting payment)") + + c.JSON(http.StatusOK, response.OK("entitlement activation marked pending", grant)) +} + +// =========================================== +// REPORTING +// =========================================== + +// grantsOrgScope computes the org visibility set for the caller: nil (no +// restriction) for owner org and Super Admin, the caller's hierarchy +// otherwise — buyers see their own modules/expirations, owner sees the fleet. +func grantsOrgScope(u *models.User) ([]string, error) { + if isEntitlementAdmin(u) { + return nil, nil + } + return local.NewUserService().GetHierarchicalOrganizationIDs(u.OrgRole, u.OrganizationID) +} + +// GetEntitlementGrants handles GET /api/entitlements/grants — fleet-wide (or +// hierarchy-wide) grants report with filters: entitlement, organization_id, +// source, active=true, expiring_before=RFC3339, page/page_size. +func GetEntitlementGrants(c *gin.Context) { + u, found := helpers.GetUserFromContext(c) + if !found { + return + } + + scope, err := grantsOrgScope(u) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to resolve org scope") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve organization scope", nil)) + return + } + + filter := entities.GrantsReportFilter{ + Entitlement: c.Query("entitlement"), + OrganizationID: c.Query("organization_id"), + Source: c.Query("source"), + ActiveOnly: c.Query("active") == "true", + OrgScope: scope, + } + if v := c.Query("expiring_before"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + c.JSON(http.StatusBadRequest, response.BadRequest("expiring_before must be RFC3339", nil)) + return + } + filter.ExpiringBefore = &t + } + + page, pageSize := 1, 50 + if v, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && v > 0 { + page = v + } + if v, err := strconv.Atoi(c.DefaultQuery("page_size", "50")); err == nil && v > 0 && v <= 200 { + pageSize = v + } + + repo := entities.NewLocalSystemEntitlementRepository() + rows, total, err := repo.ListGrants(filter, pageSize, (page-1)*pageSize) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to list grants") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to list grants", nil)) + return + } + + // Buyer identity is only shown within the viewer's hierarchy. + for _, row := range rows { + redactPurchaser(&row.SystemEntitlement, scope) + } + + c.JSON(http.StatusOK, response.OK("grants retrieved successfully", gin.H{ + "grants": rows, + "total": total, + "page": page, + "page_size": pageSize, + })) +} + +// GetEntitlementReport handles GET /api/entitlements/report — the fleet-wide +// add-on analytics (lifecycle totals, per-type/org/tier breakdowns, renewal +// distribution, 12-month activation trend). Owner org / Super Admin only: +// this is the commercial overview of everyone's licences. +func GetEntitlementReport(c *gin.Context) { + u, found := helpers.GetUserFromContext(c) + if !found { + return + } + if !isEntitlementAdmin(u) { + c.JSON(http.StatusForbidden, response.Forbidden("only the owner organization or a Super Admin can access the add-on report", nil)) + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + report, err := repo.Report() + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to build entitlement report") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to build entitlement report", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("entitlement report retrieved successfully", report)) +} + +// reportGate rejects non entitlement-admins and parses the standard +// search/page/page_size query of the paginated report slices. +func reportGate(c *gin.Context) (search string, page, pageSize int, ok bool) { + u, found := helpers.GetUserFromContext(c) + if !found { + return "", 0, 0, false + } + if !isEntitlementAdmin(u) { + c.JSON(http.StatusForbidden, response.Forbidden("only the owner organization or a Super Admin can access the add-on report", nil)) + return "", 0, 0, false + } + + search = c.Query("search") + page, pageSize = 1, 10 + if v, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && v > 0 { + page = v + } + if v, err := strconv.Atoi(c.DefaultQuery("page_size", "10")); err == nil && v > 0 && v <= 200 { + pageSize = v + } + return search, page, pageSize, true +} + +// GetEntitlementReportOrganizations handles GET /api/entitlements/report/organizations +// — the paginated + searchable per-organization slice of the add-on report. +func GetEntitlementReportOrganizations(c *gin.Context) { + search, page, pageSize, ok := reportGate(c) + if !ok { + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + rows, total, err := repo.ReportOrganizations(search, pageSize, (page-1)*pageSize) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to build per-organization report") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to build per-organization report", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("report organizations retrieved successfully", gin.H{ + "organizations": rows, + "total": total, + "page": page, + "page_size": pageSize, + })) +} + +// GetEntitlementReportTiers handles GET /api/entitlements/report/tiers — the +// paginated + searchable per-tier slice of the add-on report. +func GetEntitlementReportTiers(c *gin.Context) { + search, page, pageSize, ok := reportGate(c) + if !ok { + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + rows, total, err := repo.ReportVariants(search, pageSize, (page-1)*pageSize) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to build per-tier report") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to build per-tier report", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("report tiers retrieved successfully", gin.H{ + "tiers": rows, + "total": total, + "page": page, + "page_size": pageSize, + })) +} + +// GetEntitlementStats handles GET /api/entitlements/stats — active grants +// per entitlement per organization, within the caller's visibility. +func GetEntitlementStats(c *gin.Context) { + u, found := helpers.GetUserFromContext(c) + if !found { + return + } + + scope, err := grantsOrgScope(u) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to resolve org scope") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve organization scope", nil)) + return + } + + repo := entities.NewLocalSystemEntitlementRepository() + stats, err := repo.Stats(scope) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to compute stats") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to compute entitlement stats", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("entitlement stats retrieved successfully", gin.H{"stats": stats})) +} + +// =========================================== +// SYSTEM ENTITLEMENTS +// =========================================== + +// ListSystemEntitlements handles GET /api/systems/:id/entitlements +func ListSystemEntitlements(c *gin.Context) { + system, user, ok := entitlementAccessCheck(c, false) + if !ok { + return + } + systemID := system.ID + + repo := entities.NewLocalSystemEntitlementRepository() + list, err := repo.ListBySystem(systemID) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err). + Str("system_id", systemID). + Msg("Failed to list entitlements") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to list entitlements", nil)) + return + } + + if isSystemBlocked(system) { + for _, e := range list { + e.Status = models.EntitlementStatus(e.Active, e.RevokedAt, true, e.PendingRef) + } + } + + // Buyer identity is only shown within the viewer's hierarchy. + scope, err := grantsOrgScope(user) + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to resolve org scope") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve organization scope", nil)) + return + } + for _, e := range list { + redactPurchaser(e, scope) + } + + c.JSON(http.StatusOK, response.OK("entitlements retrieved successfully", gin.H{ + "entitlements": list, + })) +} + +// CreateSystemEntitlement handles POST /api/systems/:id/entitlements +func CreateSystemEntitlement(c *gin.Context) { + system, user, ok := entitlementAccessCheck(c, true) + if !ok { + return + } + systemID := system.ID + + var req models.CreateSystemEntitlementRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + catalogRepo := entities.NewLocalEntitlementCatalogRepository() + item, err := catalogRepo.Get(req.Entitlement) + if err == entities.ErrCatalogItemNotFound { + c.JSON(http.StatusBadRequest, response.BadRequest("unknown entitlement", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err).Msg("Failed to read entitlement catalog") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to read entitlement catalog", nil)) + return + } + + if req.Scope != "" && !item.Scoped { + c.JSON(http.StatusBadRequest, response.BadRequest("this entitlement does not support per-application scope", nil)) + return + } + + systemType := "" + if system.Type != nil { + systemType = *system.Type + } + if systemTypeMismatch(item, systemType) { + c.JSON(http.StatusBadRequest, response.BadRequest("this entitlement applies to "+item.SystemType+" systems only", nil)) + return + } + + source := req.Source + switch source { + case "": + source = models.EntitlementSourceManual + case models.EntitlementSourceManual, models.EntitlementSourceShop, models.EntitlementSourceLegacyImport: + default: + c.JSON(http.StatusBadRequest, response.BadRequest("invalid source", nil)) + return + } + + createdBy := map[string]interface{}{ + "user_id": user.ID, + "user_name": user.Name, + "organization_id": user.OrganizationID, + "organization_name": user.OrganizationName, + } + + repo := entities.NewLocalSystemEntitlementRepository() + entitlement, err := repo.Create(systemID, req.Entitlement, req.Scope, source, req.SourceRef, req.ValidFrom, req.ValidUntil, createdBy, resolvePurchaser(req.BuyerEmail)) + if err == entities.ErrEntitlementExists { + c.JSON(http.StatusConflict, response.Conflict("entitlement already exists for this system", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err). + Str("system_id", systemID). + Str("entitlement", req.Entitlement). + Msg("Failed to create entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to create entitlement", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "create_entitlement"). + Str("system_id", systemID). + Str("entitlement", req.Entitlement). + Str("scope", req.Scope). + Str("source", source). + Msg("Entitlement created") + + entitlement.Status = models.EntitlementStatus(entitlement.Active, entitlement.RevokedAt, isSystemBlocked(system), entitlement.PendingRef) + c.JSON(http.StatusCreated, response.Created("entitlement created successfully", entitlement)) +} + +// UpdateSystemEntitlement handles PUT /api/systems/:id/entitlements/:entitlement[?scope=] +func UpdateSystemEntitlement(c *gin.Context) { + system, _, ok := entitlementAccessCheck(c, true) + if !ok { + return + } + systemID := system.ID + entitlementID := c.Param("entitlement") + scope := c.Query("scope") + + var req models.UpdateSystemEntitlementRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + c.JSON(http.StatusBadRequest, response.ValidationBadRequestMultiple(err)) + return + } + + setValidUntil := req.ClearValidUntil || req.ValidUntil != nil + validUntil := req.ValidUntil + if req.ClearValidUntil { + validUntil = nil + } + + repo := entities.NewLocalSystemEntitlementRepository() + entitlement, err := repo.Update(systemID, entitlementID, scope, setValidUntil, validUntil, req.Revoked, models.EntitlementSourceManual) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("entitlement not found for this system", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err). + Str("system_id", systemID). + Str("entitlement", entitlementID). + Msg("Failed to update entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to update entitlement", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "update_entitlement"). + Str("system_id", systemID). + Str("entitlement", entitlementID). + Str("scope", scope). + Msg("Entitlement updated") + + entitlement.Status = models.EntitlementStatus(entitlement.Active, entitlement.RevokedAt, isSystemBlocked(system), entitlement.PendingRef) + c.JSON(http.StatusOK, response.OK("entitlement updated successfully", entitlement)) +} + +// DeleteSystemEntitlement handles DELETE /api/systems/:id/entitlements/:entitlement[?scope=] +// It revokes the grant (sets revoked_at) keeping the row for audit; idempotent. +func DeleteSystemEntitlement(c *gin.Context) { + system, _, ok := entitlementAccessCheck(c, true) + if !ok { + return + } + systemID := system.ID + entitlementID := c.Param("entitlement") + scope := c.Query("scope") + + repo := entities.NewLocalSystemEntitlementRepository() + entitlement, err := repo.Revoke(systemID, entitlementID, scope, models.EntitlementSourceManual) + if err == entities.ErrEntitlementNotFound { + c.JSON(http.StatusNotFound, response.NotFound("entitlement not found for this system", nil)) + return + } + if err != nil { + logger.RequestLogger(c, "entitlements").Error().Err(err). + Str("system_id", systemID). + Str("entitlement", entitlementID). + Msg("Failed to revoke entitlement") + c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to revoke entitlement", nil)) + return + } + + logger.RequestLogger(c, "entitlements").Info(). + Str("operation", "revoke_entitlement"). + Str("system_id", systemID). + Str("entitlement", entitlementID). + Str("scope", scope). + Msg("Entitlement revoked") + + entitlement.Status = models.EntitlementStatus(entitlement.Active, entitlement.RevokedAt, isSystemBlocked(system), entitlement.PendingRef) + c.JSON(http.StatusOK, response.OK("entitlement revoked successfully", entitlement)) +} diff --git a/backend/models/entitlements.go b/backend/models/entitlements.go new file mode 100644 index 000000000..184102ec6 --- /dev/null +++ b/backend/models/entitlements.go @@ -0,0 +1,345 @@ +/* +Copyright (C) 2026 Nethesis S.r.l. +SPDX-License-Identifier: AGPL-3.0-or-later +*/ + +package models + +import "time" + +// Entitlement grant sources. +const ( + EntitlementSourceManual = "manual" + EntitlementSourceShop = "shop" + EntitlementSourceLegacyImport = "legacy-import" +) + +// Entitlement grant lifecycle statuses (server-computed, the single source +// of truth for UI badges). Revoked and expired are facts of the grant +// itself; suspended means the grant is fine but the system — directly or +// through its organization's suspension/deletion cascade — cannot use it +// (collect rejects its credentials before even looking at grants). +const ( + EntitlementStatusActive = "active" + EntitlementStatusExpired = "expired" + EntitlementStatusRevoked = "revoked" + EntitlementStatusSuspended = "suspended" + EntitlementStatusPending = "pending" +) + +// EntitlementStatus derives the lifecycle status of a grant. `active` is the +// SQL-computed grant validity (not revoked, not expired), `systemBlocked` +// whether the owning system is suspended or deleted, `pendingRef` a shop +// order awaiting payment. Pending masks revoked/expired (an order is already +// on its way — the UI must not offer another purchase) but never an active +// grant (a pending renewal doesn't change what the customer has today); +// suspension masks otherwise-active grants only. +func EntitlementStatus(active bool, revokedAt *time.Time, systemBlocked bool, pendingRef string) string { + switch { + case active && systemBlocked: + return EntitlementStatusSuspended + case active: + return EntitlementStatusActive + case pendingRef != "": + return EntitlementStatusPending + case revokedAt != nil: + return EntitlementStatusRevoked + default: + return EntitlementStatusExpired + } +} + +// EntitlementCatalogItem is one grantable add-on type. The 3 legacy ng-* ids +// are the wire ids the appliance feeds call on /auth/service/ — never +// rename them. New ids follow the convention: nsec- (firewall +// services), ns8- (application enablement on a cluster), - +// (per-application-instance modules, Scoped=true). +// Catalog item kinds — both sellable on the shop: services are firewall +// add-ons granted system-wide, modules are add-ons for a single application +// instance of an NS8 cluster. +const ( + EntitlementKindService = "service" + EntitlementKindModule = "module" +) + +type EntitlementCatalogItem struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Scoped bool `json:"scoped"` + Kind string `json:"kind"` + SystemType string `json:"system_type,omitempty"` + LegacyAlias string `json:"legacy_alias,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateEntitlementCatalogRequest adds a new add-on type to the catalog. +// LegacyAlias is the old wire id consumers still call on /auth/service/ +// (only needed for types migrated from the legacy my). +type CreateEntitlementCatalogRequest struct { + ID string `json:"id" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` + Description string `json:"description,omitempty"` + Scoped bool `json:"scoped,omitempty"` + Kind string `json:"kind,omitempty"` + SystemType string `json:"system_type,omitempty"` + LegacyAlias string `json:"legacy_alias,omitempty"` +} + +// UpdateEntitlementCatalogRequest updates the display fields of a catalog +// item. The id and scoped flag are immutable (grants may already reference +// them with the current semantics). +type UpdateEntitlementCatalogRequest struct { + DisplayName *string `json:"display_name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// EntitlementAvailability is one commercial unlock: the catalog item can be +// bought/self-activated by a whole hierarchy role OR by one specific +// organization (exactly one of the two is set). It does not affect /auth +// enforcement. +type EntitlementAvailability struct { + ID string `json:"id"` + Entitlement string `json:"entitlement"` + OrgRole string `json:"org_role,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + CreatedBy map[string]interface{} `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateEntitlementAvailabilityRequest unlocks a catalog item for a role or +// a specific organization. +type CreateEntitlementAvailabilityRequest struct { + OrgRole string `json:"org_role,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` +} + +// EntitlementGrantReportRow is one row of the fleet-wide grants report +// (owner/Super Admin): the grant plus the system identity it belongs to. +type EntitlementGrantReportRow struct { + SystemEntitlement + SystemName string `json:"system_name"` + SystemKey string `json:"system_key"` + OrganizationID string `json:"organization_id"` + OrganizationName string `json:"organization_name"` +} + +// EntitlementStatsRow aggregates active grants per entitlement per org. +type EntitlementStatsRow struct { + Entitlement string `json:"entitlement"` + OrganizationID string `json:"organization_id"` + OrganizationName string `json:"organization_name"` + ActiveGrants int `json:"active_grants"` +} + +// EntitlementReport is the owner/Super Admin analytics snapshot of the whole +// add-on fleet: lifecycle totals, the per-type breakdown, the renewal +// distribution and an activation trend. The per-organization and per-tier +// breakdowns live on their own paginated+searchable endpoints +// (/report/organizations, /report/tiers) — orgs can be hundreds. Deleted +// systems are excluded everywhere. +type EntitlementReport struct { + Totals EntitlementReportTotals `json:"totals"` + ByEntitlement []EntitlementReportByType `json:"by_entitlement"` + Renewals EntitlementReportRenewals `json:"renewals"` + Trend []EntitlementReportTrendRow `json:"trend"` +} + +// EntitlementReportTotals is the fleet-wide lifecycle breakdown. Statuses +// follow the same precedence as EntitlementStatus; Perpetual counts active +// grants without an expiry (legacy imports); the Expiring* buckets count +// active grants whose expiry falls within the window (cumulative). +type EntitlementReportTotals struct { + Total int `json:"total"` + Active int `json:"active"` + Expired int `json:"expired"` + Revoked int `json:"revoked"` + Pending int `json:"pending"` + Suspended int `json:"suspended"` + Perpetual int `json:"perpetual"` + ExpiringIn30d int `json:"expiring_in_30d"` + ExpiringIn60d int `json:"expiring_in_60d"` + ExpiringIn90d int `json:"expiring_in_90d"` + Systems int `json:"systems"` // distinct systems with at least one grant + Organizations int `json:"organizations"` // distinct orgs owning those systems + // Breakdown of Systems by the hierarchy role of the owning org (the + // four sum up to Systems). + DistributorSystems int `json:"distributor_systems"` + ResellerSystems int `json:"reseller_systems"` + CustomerSystems int `json:"customer_systems"` + OwnerSystems int `json:"owner_systems"` + TotalRenewals int `json:"total_renewals"` // sum of renewal_count over all grants +} + +// EntitlementReportByType is the lifecycle breakdown of one add-on type. +type EntitlementReportByType struct { + Entitlement string `json:"entitlement"` + DisplayName string `json:"display_name"` + Active int `json:"active"` + Expired int `json:"expired"` + Revoked int `json:"revoked"` + Pending int `json:"pending"` + Suspended int `json:"suspended"` + Total int `json:"total"` +} + +// EntitlementReportByOrg aggregates grants per organization owning the +// systems, with the reseller/distributor hierarchy role for grouping. +type EntitlementReportByOrg struct { + OrganizationID string `json:"organization_id"` + OrganizationName string `json:"organization_name"` + OrgType string `json:"org_type"` + Systems int `json:"systems"` + Active int `json:"active"` + Total int `json:"total"` +} + +// EntitlementReportByVariant counts grants per shop tier of one add-on. +type EntitlementReportByVariant struct { + Entitlement string `json:"entitlement"` + Label string `json:"label"` + Count int `json:"count"` +} + +// EntitlementReportRenewals is the renewal distribution across grants. +type EntitlementReportRenewals struct { + Never int `json:"never"` + Once int `json:"once"` + Twice int `json:"twice"` + ThreePlus int `json:"three_plus"` +} + +// EntitlementReportTrendRow is one month of the activation trend (grants +// created in that month, by created_at). +type EntitlementReportTrendRow struct { + Month string `json:"month"` // YYYY-MM + Activations int `json:"activations"` +} + +// SystemEntitlement is one add-on grant for one system, optionally narrowed +// to one application instance via Scope ("" = whole system). Active is +// derived: not revoked and not expired (valid_until NULL = perpetual). +type SystemEntitlement struct { + ID string `json:"id"` + SystemID string `json:"system_id"` + Entitlement string `json:"entitlement"` + Scope string `json:"scope,omitempty"` + Source string `json:"source"` + SourceRef string `json:"source_ref,omitempty"` + ValidFrom time.Time `json:"valid_from"` + ValidUntil *time.Time `json:"valid_until,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + // RevokedSource records WHO revoked: "manual" (admin, deliberate — not + // re-buyable from the shop) or "shop" (deactivate webhook: subscription + // cancelled / payment failed — re-buyable). Empty when not revoked. + RevokedSource string `json:"revoked_source,omitempty"` + // PendingRef is the shop order placed at checkout and not yet paid + // (bank transfer/RiBa can take days): display-only, so the UI shows + // "pending" instead of offering another purchase. Enforcement ignores + // it. Cleared by activate (payment confirmed) or cancel. + PendingRef string `json:"pending_ref,omitempty"` + PendingSince *time.Time `json:"pending_since,omitempty"` + Active bool `json:"active"` + Status string `json:"status"` // active | expired | revoked | suspended | pending (see EntitlementStatus) + CreatedBy map[string]interface{} `json:"created_by,omitempty"` + // PurchasedBy is the audit snapshot of the my user that BOUGHT the grant + // on the shop, resolved from the order's customer email (webhook + // activation, or the legacy-import backfill when the expiry map carries + // the order email): {logto_id, name, email, organization_id, + // organization_name, org_role, user_roles} — {email} only when the + // address matches no my user, nil for manual grants and legacy rows + // without an order. CreatedBy stays the webhook/import actor (owner + // key); this is the real buyer. Read endpoints redact it to + // {out_of_scope: true} when the buyer's organization is outside the + // viewer's hierarchy. + PurchasedBy map[string]interface{} `json:"purchased_by,omitempty"` + // Variant is the shop variation (tier) of the purchased product line, + // {id, sku, label} (e.g. label "16-30 device"). Display metadata only: + // the add-on mapping stays on the parent product and /auth enforcement + // ignores it. Refreshed by activate, so upgrades/downgrades follow the + // renewals; nil for manual grants and simple products. + Variant map[string]interface{} `json:"variant,omitempty"` + // RenewalCount is the number of paid shop orders on this grant beyond + // the first: activate increments it when source_ref CHANGES (webhook + // retries on the same order never double-count). 0 = first period. + RenewalCount int `json:"renewal_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateSystemEntitlementRequest grants an add-on to a system. Scope narrows +// the grant to one application instance and is only accepted for catalog +// items with Scoped=true. +type CreateSystemEntitlementRequest struct { + Entitlement string `json:"entitlement" binding:"required"` + Scope string `json:"scope,omitempty"` + ValidFrom *time.Time `json:"valid_from,omitempty"` // override (legacy import: the original order date); defaults to now + ValidUntil *time.Time `json:"valid_until,omitempty"` + Source string `json:"source,omitempty"` + SourceRef string `json:"source_ref,omitempty"` + // BuyerEmail is the customer email of the originating shop order (legacy + // import backfill): resolved to a my user for the purchased_by snapshot, + // kept raw when no user matches. See ActivateEntitlementRequest. + BuyerEmail string `json:"buyer_email,omitempty"` +} + +// ActivateEntitlementRequest is the shop-facing activation/renewal call +// (webhook after purchase or subscription renewal). The system is addressed +// by its key (the shop never sees internal ids); the entitlement accepts the +// canonical id or the legacy alias. Idempotent: an existing grant is renewed +// in place (new expiry, revocation cleared). +type ActivateEntitlementRequest struct { + SystemKey string `json:"system_key" binding:"required"` + Entitlement string `json:"entitlement" binding:"required"` + Scope string `json:"scope,omitempty"` + ValidUntil *time.Time `json:"valid_until,omitempty"` + SourceRef string `json:"source_ref,omitempty"` + // BuyerEmail is the email of the WordPress customer that owns the order + // (server-to-server, trusted): the backend resolves it to a my user and + // stores the purchased_by audit snapshot. Empty on stamped legacy orders. + BuyerEmail string `json:"buyer_email,omitempty"` + // Variant is the shop variation (tier) of the order line, {id, sku, + // label}; stored as display metadata on the grant. + Variant map[string]interface{} `json:"variant,omitempty"` +} + +// DeactivateEntitlementRequest revokes a shop-managed grant (subscription +// cancelled/expired). When SourceRef is set it is matched against the grant: +// a pending activation with the same ref is cleared instead of revoked, and +// a grant owned by a DIFFERENT ref is left untouched (cancelling an old +// order must not kill an entitlement another order paid for). +type DeactivateEntitlementRequest struct { + SystemKey string `json:"system_key" binding:"required"` + Entitlement string `json:"entitlement" binding:"required"` + Scope string `json:"scope,omitempty"` + SourceRef string `json:"source_ref,omitempty"` +} + +// PendingEntitlementRequest marks a shop order placed at checkout and not +// yet paid. SourceRef is required: it correlates the pending marker with the +// activate/cancel that will follow. +type PendingEntitlementRequest struct { + SystemKey string `json:"system_key" binding:"required"` + Entitlement string `json:"entitlement" binding:"required"` + Scope string `json:"scope,omitempty"` + SourceRef string `json:"source_ref" binding:"required"` + // BuyerEmail: see ActivateEntitlementRequest. Stamped on fresh pending + // stubs only — a pending renewal must not overwrite who bought the grant + // the customer currently has. + BuyerEmail string `json:"buyer_email,omitempty"` + // Variant: see ActivateEntitlementRequest. Fresh pending stubs only, + // like BuyerEmail — the not-yet-paid tier must not overwrite the one the + // customer currently has. + Variant map[string]interface{} `json:"variant,omitempty"` +} + +// UpdateSystemEntitlementRequest extends/reduces the expiry or toggles the +// revoked state. ClearValidUntil makes the grant perpetual (valid_until NULL); +// it wins over ValidUntil when both are set. The target grant is addressed by +// path (:entitlement) + optional ?scope= query. +type UpdateSystemEntitlementRequest struct { + ValidUntil *time.Time `json:"valid_until,omitempty"` + ClearValidUntil bool `json:"clear_valid_until,omitempty"` + Revoked *bool `json:"revoked,omitempty"` +} diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 774e51efa..9cc5826f0 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -62,6 +62,8 @@ tags: description: System statistics, trends and exports - name: Backend - Systems Inventory description: System inventory history, diffs and timeline + - name: Backend - Entitlements + description: "Granular add-on licensing: catalog, grants, availability and shop activation" - name: Backend - Applications description: Application CRUD and organization assignment @@ -100,6 +102,8 @@ tags: description: Collect service inventory and heartbeat collection - name: Collect - Rebranding description: Collect service rebranding endpoints for systems + - name: Collect - Feed Authorization + description: Feed authorization (/auth forwardAuth) — subscription-level and per-add-on entitlement checks for the appliance enterprise feeds - name: Collect - Alerting description: | Collect service alerting proxy to Mimir Alertmanager. @@ -2194,458 +2198,1073 @@ components: description: Additional notes or description for the system example: "Production web server for EU region" - # Applications schemas - Application: + # Entitlements schemas + EntitlementCatalogItem: type: object + description: | + One grantable add-on type. Ids are lowercase kebab-case; convention: + `nsec-` (firewall services), `ns8-` (application + enablement on a cluster) or `-` (per-application-instance + modules, `scoped: true`). `legacy_alias` is the old wire id the + appliance feeds still call on `GET /auth/service/{id}` — legacy ids + are never renamed. properties: id: type: string - description: Unique application identifier (system_id + module_id) - example: "sys_abc123_mail1" - system_id: - type: string - description: ID of the system hosting this application - example: "sys_abc123" - module_id: - type: string - description: Module identifier within the system - example: "mail1" - instance_of: - type: string - description: Application type (e.g., nethvoice, webtop, mail) - example: "mail" - name: - type: string - nullable: true - description: Human-readable label from inventory (e.g., "Nextcloud") - example: "Nextcloud" - source: - type: string - nullable: true - description: Image source from inventory (e.g., "ghcr.io/nethserver/nextcloud") - example: "ghcr.io/nethserver/nextcloud" + description: Catalog id (lowercase kebab-case, immutable) + example: "nsec-blacklist" display_name: type: string - nullable: true - description: User-friendly name set in NS8 and inherited from inventory (e.g., "Milan Office PBX") - example: "Milan Office PBX" - node_id: - type: integer - nullable: true - description: Node ID within the cluster - example: 1 - node_label: - type: string - nullable: true - description: Node label from inventory (e.g., Leader Node, Worker Node) - example: "Leader Node" - version: - type: string - nullable: true - description: Application version - example: "1.2.3" - organization_id: - type: string - nullable: true - description: Assigned organization ID - example: "org_xyz789" - organization_type: + description: Human-readable name + example: "Blacklist" + description: type: string - nullable: true - enum: [distributor, reseller, customer] - description: Type of the assigned organization - example: "customer" - status: + description: Optional longer description + example: "IP and DNS blacklist feed for NethSecurity" + scoped: + type: boolean + description: True when grants can be narrowed to a single application instance via scope (per-application-instance modules). Immutable. + example: false + kind: type: string - enum: [unassigned, assigned] - description: Assignment status - example: "assigned" - inventory_data: - type: object - nullable: true - description: Raw inventory data from system - additionalProperties: true - backup_data: - type: object - nullable: true - description: Backup status information - additionalProperties: true - services_data: - type: object - nullable: true - description: Services health status - additionalProperties: true - url: + description: Both kinds are sellable on the shop — services are firewall add-ons granted system-wide, modules are add-ons for a single application instance of an NS8 cluster + enum: [service, module] + example: "service" + system_type: type: string - nullable: true - description: Application URL - example: "https://cluster.example.com/cluster-admin/#/apps/mail1" - notes: + description: Restricts the add-on to one system type. Empty/omitted = any type. + enum: [nsec, ns8] + example: "nsec" + legacy_alias: type: string - nullable: true - description: Additional notes - example: "Primary mail server for corporate domain" - is_user_facing: - type: boolean - description: Whether this is a user-facing application (vs system component) - example: true + description: Old wire id consumers still call on GET /auth/service/{id} (only for types migrated from the legacy my) + example: "ng-blacklist" created_at: type: string format: date-time - description: Record creation timestamp - example: "2025-07-01T09:00:00Z" + example: "2026-07-01T10:00:00Z" updated_at: type: string format: date-time - description: Last update timestamp - example: "2025-07-10T10:30:00Z" - first_seen_at: + example: "2026-07-01T10:00:00Z" + + EntitlementCreator: + type: object + description: Snapshot of the user who created the grant or availability rule + properties: + user_id: type: string - format: date-time - description: First time the application was seen in inventory - example: "2025-06-15T08:00:00Z" - last_inventory_at: + description: Logto ID of the creator + example: "53h5zxpwu4vc" + user_name: type: string - format: date-time - nullable: true - description: Last inventory collection timestamp - example: "2025-07-21T10:25:00Z" - deleted_at: + description: Full name of the creator + example: "Edoardo Super" + organization_id: type: string - format: date-time - nullable: true - description: Soft delete timestamp (null if not deleted) - example: null - rebranding_enabled: - type: boolean - description: Whether rebranding is active for this application (direct or inherited) - example: true - rebranding_org_id: + description: Organization ID of the creator + example: "lbswt1rxdhbz" + organization_name: type: string - nullable: true - description: Logto organization ID (logto_id) that provides the rebranding assets (the org itself or an ancestor). Use this value as the :org_id parameter in /api/rebranding/:org_id/* endpoints. - example: "org_dist001" - system: - $ref: '#/components/schemas/ApplicationSystemSummary' - organization: - $ref: '#/components/schemas/OrganizationSummary' + description: Organization name of the creator + example: "Nethesis Italia" + channel: + type: string + description: Set to "shop" when the grant was created via POST /entitlements/activate. Omitted otherwise. + example: "shop" - ApplicationListItem: + SystemEntitlement: type: object - description: Simplified application representation for list views + description: | + One add-on grant for one system, optionally narrowed to a single + application instance via `scope` (omitted = whole system). `active` is + derived: not revoked and not expired (`valid_until` null = perpetual). + Revocation is soft (sets `revoked_at`, the row is kept for audit). properties: id: type: string - description: Unique application identifier - example: "sys_abc123_mail1" - module_id: + format: uuid + description: Grant ID + example: "f47ac10b-58cc-4372-a567-0e02b2c3d479" + system_id: type: string - description: Module identifier - example: "mail1" - instance_of: + description: ID of the system the grant belongs to + example: "550e8400-e29b-41d4-a716-446655440000" + entitlement: type: string - description: Application type - example: "mail" - name: + description: Catalog id of the granted add-on + example: "nsec-blacklist" + scope: type: string - nullable: true - description: Human-readable label from inventory (e.g., "Nextcloud") - example: "Nextcloud" + description: Application instance the grant is narrowed to (only for scoped catalog items). Omitted = whole system. + example: "nethvoice1" source: type: string - nullable: true - description: Image source from inventory (e.g., "ghcr.io/nethserver/nextcloud") - example: "ghcr.io/nethserver/nextcloud" - display_name: - type: string - nullable: true - description: User-friendly name set in NS8 and inherited from inventory - example: "Milan Office PBX" - version: + description: How the grant was created + enum: [manual, shop, legacy-import] + example: "manual" + source_ref: type: string - nullable: true - description: Application version - example: "1.2.3" - status: + description: Free-form reference to the originating record (e.g. shop subscription id) + example: "sub_12345" + valid_from: type: string - enum: [unassigned, assigned] - description: Assignment status - example: "assigned" - node_id: - type: integer - nullable: true - description: Node ID within the cluster - example: 1 - node_label: + format: date-time + description: When the grant became valid + example: "2026-07-01T10:00:00Z" + valid_until: type: string + format: date-time nullable: true - description: Node label from inventory (e.g., Leader Node, Worker Node) - example: "Worker Node" - url: + description: Expiry of the grant. Null = perpetual. + example: "2027-07-01T10:00:00Z" + revoked_at: type: string + format: date-time nullable: true - description: Custom URL for the application - example: "https://cluster.example.com/cluster-admin/#/apps/mail1" - notes: + description: When the grant was revoked. Null = not revoked. + example: null + revoked_source: type: string - nullable: true - description: Custom notes or description for the application - example: "Corporate email server for marketing department" - has_errors: + enum: [manual, shop] + description: | + Who revoked the grant: `manual` (admin via API/UI — deliberate, + the shop button is not offered again) or `shop` (deactivate + webhook: subscription cancelled or payment failed — buyable + again). Omitted when not revoked. + example: "shop" + active: type: boolean - description: Whether the application has service errors - example: false - inventory_data: - type: object - nullable: true - description: Raw inventory data from the module (e.g., NethVoice proxy, Open LDAP domain, etc.) - example: {"nethvoice_proxy": "nethvoice-proxy1", "internal_openldap": "mydomain.com"} - backup_data: - type: object - nullable: true - description: Backup status information - example: {"status": "success", "destination": "BlackBlaze B1", "completed_at": "2025-09-16T12:30:00Z", "duration_seconds": 106, "total_size_bytes": 9185231897, "total_files": 5759} - services_data: - type: object + description: "Derived: not revoked and not expired" + example: true + pending_ref: + type: string + description: Shop order placed at checkout and not yet paid (display-only; cleared by activate or cancel). Omitted when nothing is pending. + example: "wc-order-83164" + pending_since: + type: string + format: date-time nullable: true - description: Service health status information with errors list - example: {"has_errors": true, "error_count": 1, "services": [{"name": "NethVoice CTI server", "status": "error", "error": "is not running", "since": "2025-09-16T23:10:00Z"}]} - system: - $ref: '#/components/schemas/ApplicationSystemSummary' - organization: - $ref: '#/components/schemas/OrganizationSummary' + description: When the pending order was placed + example: "2026-07-15T18:00:00Z" + status: + type: string + enum: [active, expired, revoked, suspended, pending] + description: | + Server-computed lifecycle status. `suspended` = the grant is + intact but the owning system (or its organization) is suspended + or deleted, so collect rejects its credentials. `pending` = an + order is awaiting payment (masks revoked/expired but never an + active grant). Enforcement authorizes only truly active grants. + example: "active" + created_by: + $ref: '#/components/schemas/EntitlementCreator' + purchased_by: + $ref: '#/components/schemas/EntitlementPurchaser' + variant: + $ref: '#/components/schemas/EntitlementVariant' + renewal_count: + type: integer + description: | + Paid shop orders on this grant beyond the first: activate + increments it when source_ref changes (webhook retries on the + same order never double-count). 0 = first period. + example: 3 created_at: type: string format: date-time - description: Record creation timestamp - example: "2025-07-01T09:00:00Z" - last_inventory_at: + example: "2026-07-01T10:00:00Z" + updated_at: type: string format: date-time - nullable: true - description: Last inventory collection timestamp - example: "2025-07-21T10:25:00Z" + example: "2026-07-01T10:00:00Z" - ApplicationSystemSummary: + EntitlementPurchaser: type: object - description: Minimal system info for application responses + description: | + Audit snapshot of the my user that BOUGHT the grant on the shop, + resolved from the order's customer email (webhook activation, or the + legacy-import backfill when the expiry map carries the order email) + and frozen at purchase time (robust to later renames/moves). Omitted + for manual grants and legacy rows without an order. When the email + matches no my user only `email` is present. When the buyer's + organization is outside the viewer's hierarchy the snapshot is + redacted to `{out_of_scope: true}` (owner org and Super Admin always + see it in full). properties: - id: + logto_id: type: string - description: System ID - example: "sys_abc123" + description: Logto ID of the buyer + example: "53h5zxpwu4vc" name: type: string - description: System name - example: "Production Cluster" - - ApplicationTotals: - type: object - description: Application statistics - properties: - total: - type: integer - description: Total number of applications - example: 150 - unassigned: - type: integer - description: Number of unassigned applications - example: 25 - assigned: - type: integer - description: Number of assigned applications - example: 125 - with_errors: + description: Full name of the buyer at purchase time + example: "Mario Rossi" + email: + type: string + description: Email of the buyer (the shop order's customer) + example: "mario.rossi@example.com" + organization_id: + type: string + description: Logto ID of the buyer's organization at purchase time + example: "akkbs6x2wo82" + organization_name: + type: string + description: Name of the buyer's organization at purchase time + example: "ACME S.r.l." + org_role: + type: string + description: Hierarchy role of the buyer's organization at purchase time + example: "reseller" + user_roles: + type: array + items: + type: string + description: User role names of the buyer at purchase time + example: ["Admin"] + out_of_scope: + type: boolean + description: Present (true) when the buyer identity was redacted because it is outside the viewer's hierarchy + example: true + + EntitlementVariant: + type: object + description: | + Shop variation (tier) of the purchased product line. Display metadata + only: the add-on↔product mapping stays on the parent product and + /auth enforcement ignores it. Refreshed by activate, so tier + upgrades/downgrades follow the renewals. Omitted for manual grants + and simple (non-variable) products. + properties: + id: type: integer - description: Number of applications with service errors - example: 5 - by_type: - type: object - description: Count by application type - additionalProperties: - type: integer - example: - mail: 30 - webtop: 25 - nethvoice: 20 - nextcloud: 15 - by_status: - type: object - description: Count by status - additionalProperties: - type: integer - example: - assigned: 125 - unassigned: 25 + description: WooCommerce variation id + example: 245 + sku: + type: string + description: SKU of the variation + example: "ATS-15" + label: + type: string + description: Human-readable tier, from the variation attributes + example: "1-15 device" - ApplicationType: + EntitlementAvailability: type: object - description: Application type metadata (only user-facing types are returned) + description: | + One commercial unlock for a catalog item: the add-on can be bought or + self-activated by a whole hierarchy role OR by one specific + organization (exactly one of the two is set). A catalog item without + any rule is available to everyone. Availability rules do not affect + /auth enforcement on collect. properties: - instance_of: + id: type: string - description: Application type identifier (lowercase, e.g., nethvoice, webtop, mail) - example: "nethvoice" - name: + format: uuid + description: Rule ID + example: "9f1b7c2e-4a3d-4a2b-8f6e-1c2d3e4f5a6b" + entitlement: type: string - description: Human-readable name of the application type (e.g., NethVoice, WebTop) - example: "NethVoice" - count: - type: integer - description: Number of applications of this type - example: 30 + description: Catalog id the rule applies to + example: "nsec-blacklist" + org_role: + type: string + description: Hierarchy role the item is unlocked for (mutually exclusive with organization_id) + enum: [distributor, reseller, customer] + example: "reseller" + organization_id: + type: string + description: Specific organization the item is unlocked for (mutually exclusive with org_role) + example: "akkbs6x2wo82" + created_by: + $ref: '#/components/schemas/EntitlementCreator' + created_at: + type: string + format: date-time + example: "2026-07-01T10:00:00Z" - ApplicationTypeSummary: + EntitlementGrantReportRow: + description: One row of the grants report — the grant plus the identity of the system it belongs to + allOf: + - $ref: '#/components/schemas/SystemEntitlement' + - type: object + properties: + system_name: + type: string + description: Name of the system + example: "Milan Office Firewall" + system_key: + type: string + description: Key of the system + example: "NETH-F5D2-5E69-A174-45A9-B1AB-2BB9-03F5-F1B4" + organization_id: + type: string + description: Logto ID of the organization owning the system + example: "akkbs6x2wo82" + organization_name: + type: string + description: Name of the organization owning the system + example: "ACME S.r.l." + + EntitlementReport: type: object - description: Applications grouped by type with total count and optional pagination + description: Fleet-wide add-on analytics (owner/Super Admin only) properties: - total: - type: integer - description: Total number of matching applications (always present, not paginated) - example: 130 - by_type: + totals: + type: object + properties: + total: { type: integer, example: 120 } + active: { type: integer, example: 95 } + expired: { type: integer, example: 12 } + revoked: { type: integer, example: 6 } + pending: { type: integer, example: 3 } + suspended: { type: integer, example: 4 } + perpetual: { type: integer, example: 40, description: "Active grants without an expiry (legacy imports)" } + expiring_in_30d: { type: integer, example: 8, description: "Active grants expiring within 30 days" } + expiring_in_60d: { type: integer, example: 15, description: "Active grants expiring within 60 days (cumulative)" } + expiring_in_90d: { type: integer, example: 22, description: "Active grants expiring within 90 days (cumulative)" } + systems: { type: integer, example: 80, description: "Distinct systems with at least one grant" } + organizations: { type: integer, example: 35, description: "Distinct organizations owning those systems" } + distributor_systems: { type: integer, example: 20, description: "Systems owned by distributor organizations (the four *_systems fields sum up to systems)" } + reseller_systems: { type: integer, example: 35, description: "Systems owned by reseller organizations" } + customer_systems: { type: integer, example: 22, description: "Systems owned by customer organizations" } + owner_systems: { type: integer, example: 3, description: "Systems owned by the owner organization" } + total_renewals: { type: integer, example: 60, description: "Sum of renewal_count over all grants" } + by_entitlement: type: array - description: Applications grouped by type, sorted and optionally paginated items: - $ref: '#/components/schemas/ApplicationType' - pagination: - description: Pagination metadata (only present when page_size query param is specified) - $ref: '#/components/schemas/Pagination' + type: object + properties: + entitlement: { type: string, example: "nsec-blacklist" } + display_name: { type: string, example: "Advanced Threat Shield" } + active: { type: integer, example: 60 } + expired: { type: integer, example: 8 } + revoked: { type: integer, example: 3 } + pending: { type: integer, example: 2 } + suspended: { type: integer, example: 1 } + total: { type: integer, example: 74 } + renewals: + type: object + description: Renewal distribution across grants + properties: + never: { type: integer, example: 70 } + once: { type: integer, example: 30 } + twice: { type: integer, example: 15 } + three_plus: { type: integer, example: 5 } + trend: + type: array + description: Grants created per month, last 12 months + items: + type: object + properties: + month: { type: string, example: "2026-07" } + activations: { type: integer, example: 9 } - AssignApplicationRequest: + EntitlementStatsRow: type: object - required: - - organization_id + description: Active grants aggregated per entitlement per organization properties: + entitlement: + type: string + description: Catalog id + example: "nsec-blacklist" organization_id: type: string - description: Organization Logto ID to assign to the application (use the logto_id from organization responses) + description: Logto ID of the organization example: "akkbs6x2wo82" + organization_name: + type: string + description: Name of the organization + example: "ACME S.r.l." + active_grants: + type: integer + description: Number of active grants + example: 12 - UpdateApplicationRequest: + CreateSystemEntitlementRequest: type: object - description: Request to update application notes (other fields are read-only and populated from inventory) + description: Manually grants an add-on to a system. Scope narrows the grant to one application instance and is only accepted for catalog items with scoped=true. + required: + - entitlement properties: - notes: + entitlement: + type: string + description: Catalog id of the add-on to grant + example: "nsec-blacklist" + scope: type: string + description: Application instance to narrow the grant to (only for scoped catalog items) + example: "nethvoice1" + valid_from: + type: string + format: date-time + description: Override the start date. Omitted = now. Used by the legacy import to preserve the original order date. + example: "2026-02-01T00:00:00Z" + valid_until: + type: string + format: date-time nullable: true - description: Custom notes for the application - example: "Server di posta aziendale per il reparto marketing" + description: Expiry of the grant. Null/omitted = perpetual. + example: "2027-07-01T10:00:00Z" + source: + type: string + description: How the grant was created + enum: [manual, shop, legacy-import] + default: manual + example: "manual" + source_ref: + type: string + description: Free-form reference to the originating record + example: "sub_12345" + buyer_email: + type: string + description: | + Customer email of the originating shop order (legacy-import + backfill): resolved to a my user for the `purchased_by` snapshot, + kept raw when no user matches. + example: "mario.rossi@example.com" - ImpersonationConsent: + UpdateSystemEntitlementRequest: type: object + description: Extends/reduces the expiry or toggles the revoked state of a grant. clear_valid_until makes the grant perpetual (valid_until null) and wins over valid_until when both are set. + properties: + valid_until: + type: string + format: date-time + description: New expiry of the grant + example: "2027-07-01T10:00:00Z" + clear_valid_until: + type: boolean + description: True makes the grant perpetual (valid_until null). Wins over valid_until when both are set. + example: false + revoked: + type: boolean + description: "True revokes the grant, false restores it. Omitted = unchanged." + example: false + + CreateEntitlementCatalogRequest: + type: object + description: Adds a new add-on type to the catalog. legacy_alias is the old wire id consumers still call on GET /auth/service/{id} (only needed for types migrated from the legacy my). + required: + - id + - display_name properties: id: type: string - description: Consent ID - example: "consent_123" - user_id: + description: "Catalog id: lowercase kebab-case, convention nsec-, ns8- or -" + example: "nsec-blacklist" + display_name: type: string - description: User ID who grants consent - example: "usr_456" - expires_at: + description: Human-readable name + example: "Blacklist" + description: type: string - format: date-time - description: Consent expiration timestamp - example: "2025-09-04T14:30:00Z" - max_duration_minutes: - type: integer - description: Maximum duration for each impersonation session in minutes - example: 60 - created_at: + description: Optional longer description + example: "IP and DNS blacklist feed for NethSecurity" + scoped: + type: boolean + description: True when grants can be narrowed to a single application instance (per-application-instance modules) + default: false + example: false + kind: type: string - format: date-time - description: Consent creation timestamp - example: "2025-09-03T14:30:00Z" + description: Add-on kind + enum: [service, module] + default: service + example: "service" + system_type: + type: string + description: Restricts the add-on to one system type. Empty/omitted = any type. + enum: [nsec, ns8] + example: "nsec" + legacy_alias: + type: string + description: Old wire id (lowercase kebab-case) consumers still call on GET /auth/service/{id} + example: "ng-blacklist" - ImpersonationSession: + UpdateEntitlementCatalogRequest: type: object + description: Updates the display fields of a catalog item. The id and scoped flag are immutable (grants may already reference them with the current semantics). properties: - session_id: - type: string - description: Unique session identifier - example: "sess_abc123def456" - impersonator_user_id: + display_name: type: string - description: User ID of the person doing the impersonation - example: "usr_owner_123" - impersonated_user_id: + description: Human-readable name + example: "Blacklist" + description: type: string - description: User ID of the person being impersonated - example: "usr_target_456" - impersonator_username: + description: Optional longer description + example: "IP and DNS blacklist feed for NethSecurity" + + CreateEntitlementAvailabilityRequest: + type: object + description: Unlocks a catalog item for a hierarchy role OR one specific organization. Exactly one of the two fields must be set. + properties: + org_role: type: string - description: Username of the impersonator - example: "owner@company.com" - impersonated_username: + description: Hierarchy role to unlock the item for (mutually exclusive with organization_id) + enum: [distributor, reseller, customer] + example: "reseller" + organization_id: type: string - description: Username of the impersonated user - example: "customer@example.com" - impersonator_name: + description: Specific organization to unlock the item for (mutually exclusive with org_role) + example: "akkbs6x2wo82" + + ActivateEntitlementRequest: + type: object + description: | + Shop-facing activation/renewal (webhook after purchase or subscription + renewal). The system is addressed by its key (the shop never sees + internal ids); the entitlement accepts the canonical id or the legacy + alias. Idempotent: an existing (system, entitlement, scope) grant is + renewed in place (new expiry, revocation cleared). + required: + - system_key + - entitlement + properties: + system_key: type: string - description: Full name of the impersonator - example: "John Doe" - impersonated_name: + description: Key of the system to activate the add-on on + example: "NETH-F5D2-5E69-A174-45A9-B1AB-2BB9-03F5-F1B4" + entitlement: type: string - description: Full name of the impersonated user - example: "Jane Smith" - start_time: + description: Canonical catalog id or legacy alias of the add-on + example: "nsec-blacklist" + scope: type: string - format: date-time - description: Session start timestamp - example: "2025-09-02T14:30:00Z" - end_time: + description: Application instance to narrow the grant to (only for scoped catalog items) + example: "nethvoice1" + valid_until: type: string format: date-time nullable: true - description: Session end timestamp (null if still active) - example: "2025-09-02T15:45:00Z" - duration_minutes: - type: integer - nullable: true - description: Session duration in minutes (null if still active) - example: 75 - action_count: - type: integer - description: Number of actions performed in this session - example: 24 - status: + description: Expiry of the grant. Null/omitted = perpetual. + example: "2027-07-01T10:00:00Z" + source_ref: type: string - enum: [active, completed] - description: Session status - example: "completed" - - ImpersonationAuditEntry: + description: Free-form reference to the originating record (e.g. shop subscription id) + example: "sub_12345" + buyer_email: + type: string + description: | + Email of the WordPress customer that owns the order + (server-to-server, trusted). Resolved to a my user to store the + `purchased_by` audit snapshot; an address matching no my user is + kept raw. + example: "mario.rossi@example.com" + variant: + $ref: '#/components/schemas/EntitlementVariant' + + DeactivateEntitlementRequest: type: object + description: Shop-facing revocation of a grant (subscription cancelled/expired) + required: + - system_key + - entitlement properties: - id: + system_key: type: string - description: Audit entry ID - example: "audit_xyz789abc" - session_id: + description: Key of the system to revoke the add-on from + example: "NETH-F5D2-5E69-A174-45A9-B1AB-2BB9-03F5-F1B4" + entitlement: type: string - description: Impersonation session ID - example: "sess_abc123def456" - impersonator_user_id: + description: Canonical catalog id or legacy alias of the add-on + example: "nsec-blacklist" + scope: type: string - description: User ID of the person doing the impersonation - example: "usr_owner_123" - impersonated_user_id: + description: Application instance the grant is narrowed to + example: "nethvoice1" + source_ref: type: string - description: User ID of the person being impersonated - example: "usr_target_456" - impersonator_username: + description: Free-form reference to the originating record. When set, it is matched against the grant — a pending activation with the same ref is cleared instead of revoked, and a grant owned by a different ref is left untouched. + example: "sub_12345" + + PendingEntitlementRequest: + type: object + description: Shop-facing "order placed, payment not confirmed yet" marker (display-only, no enforcement impact) + required: + - system_key + - entitlement + - source_ref + properties: + system_key: type: string - description: Username of the impersonator - example: "owner@company.com" - impersonated_username: + description: Key of the system the add-on was bought for + example: "NETH-F5D2-5E69-A174-45A9-B1AB-2BB9-03F5-F1B4" + entitlement: type: string - description: Username of the impersonated user - example: "customer@example.com" - impersonator_name: + description: Canonical catalog id or legacy alias of the add-on + example: "nethvoice-chat" + scope: type: string - description: Full name of the impersonator - example: "John Doe" + description: Application instance the grant is narrowed to + example: "nethvoice1" + source_ref: + type: string + description: Reference of the order awaiting payment; the later activate/deactivate with the same ref resolves the marker + example: "wc-order-83164" + buyer_email: + type: string + description: | + Email of the WordPress customer that owns the order + (server-to-server, trusted). Stamped in `purchased_by` on fresh + pending stubs only — a pending renewal never overwrites who + bought the currently active grant. + example: "mario.rossi@example.com" + variant: + $ref: '#/components/schemas/EntitlementVariant' + + # Applications schemas + Application: + type: object + properties: + id: + type: string + description: Unique application identifier (system_id + module_id) + example: "sys_abc123_mail1" + system_id: + type: string + description: ID of the system hosting this application + example: "sys_abc123" + module_id: + type: string + description: Module identifier within the system + example: "mail1" + instance_of: + type: string + description: Application type (e.g., nethvoice, webtop, mail) + example: "mail" + name: + type: string + nullable: true + description: Human-readable label from inventory (e.g., "Nextcloud") + example: "Nextcloud" + source: + type: string + nullable: true + description: Image source from inventory (e.g., "ghcr.io/nethserver/nextcloud") + example: "ghcr.io/nethserver/nextcloud" + display_name: + type: string + nullable: true + description: User-friendly name set in NS8 and inherited from inventory (e.g., "Milan Office PBX") + example: "Milan Office PBX" + node_id: + type: integer + nullable: true + description: Node ID within the cluster + example: 1 + node_label: + type: string + nullable: true + description: Node label from inventory (e.g., Leader Node, Worker Node) + example: "Leader Node" + version: + type: string + nullable: true + description: Application version + example: "1.2.3" + organization_id: + type: string + nullable: true + description: Assigned organization ID + example: "org_xyz789" + organization_type: + type: string + nullable: true + enum: [distributor, reseller, customer] + description: Type of the assigned organization + example: "customer" + status: + type: string + enum: [unassigned, assigned] + description: Assignment status + example: "assigned" + inventory_data: + type: object + nullable: true + description: Raw inventory data from system + additionalProperties: true + backup_data: + type: object + nullable: true + description: Backup status information + additionalProperties: true + services_data: + type: object + nullable: true + description: Services health status + additionalProperties: true + url: + type: string + nullable: true + description: Application URL + example: "https://cluster.example.com/cluster-admin/#/apps/mail1" + notes: + type: string + nullable: true + description: Additional notes + example: "Primary mail server for corporate domain" + is_user_facing: + type: boolean + description: Whether this is a user-facing application (vs system component) + example: true + created_at: + type: string + format: date-time + description: Record creation timestamp + example: "2025-07-01T09:00:00Z" + updated_at: + type: string + format: date-time + description: Last update timestamp + example: "2025-07-10T10:30:00Z" + first_seen_at: + type: string + format: date-time + description: First time the application was seen in inventory + example: "2025-06-15T08:00:00Z" + last_inventory_at: + type: string + format: date-time + nullable: true + description: Last inventory collection timestamp + example: "2025-07-21T10:25:00Z" + deleted_at: + type: string + format: date-time + nullable: true + description: Soft delete timestamp (null if not deleted) + example: null + rebranding_enabled: + type: boolean + description: Whether rebranding is active for this application (direct or inherited) + example: true + rebranding_org_id: + type: string + nullable: true + description: Logto organization ID (logto_id) that provides the rebranding assets (the org itself or an ancestor). Use this value as the :org_id parameter in /api/rebranding/:org_id/* endpoints. + example: "org_dist001" + system: + $ref: '#/components/schemas/ApplicationSystemSummary' + organization: + $ref: '#/components/schemas/OrganizationSummary' + + ApplicationListItem: + type: object + description: Simplified application representation for list views + properties: + id: + type: string + description: Unique application identifier + example: "sys_abc123_mail1" + module_id: + type: string + description: Module identifier + example: "mail1" + instance_of: + type: string + description: Application type + example: "mail" + name: + type: string + nullable: true + description: Human-readable label from inventory (e.g., "Nextcloud") + example: "Nextcloud" + source: + type: string + nullable: true + description: Image source from inventory (e.g., "ghcr.io/nethserver/nextcloud") + example: "ghcr.io/nethserver/nextcloud" + display_name: + type: string + nullable: true + description: User-friendly name set in NS8 and inherited from inventory + example: "Milan Office PBX" + version: + type: string + nullable: true + description: Application version + example: "1.2.3" + status: + type: string + enum: [unassigned, assigned] + description: Assignment status + example: "assigned" + node_id: + type: integer + nullable: true + description: Node ID within the cluster + example: 1 + node_label: + type: string + nullable: true + description: Node label from inventory (e.g., Leader Node, Worker Node) + example: "Worker Node" + url: + type: string + nullable: true + description: Custom URL for the application + example: "https://cluster.example.com/cluster-admin/#/apps/mail1" + notes: + type: string + nullable: true + description: Custom notes or description for the application + example: "Corporate email server for marketing department" + has_errors: + type: boolean + description: Whether the application has service errors + example: false + inventory_data: + type: object + nullable: true + description: Raw inventory data from the module (e.g., NethVoice proxy, Open LDAP domain, etc.) + example: {"nethvoice_proxy": "nethvoice-proxy1", "internal_openldap": "mydomain.com"} + backup_data: + type: object + nullable: true + description: Backup status information + example: {"status": "success", "destination": "BlackBlaze B1", "completed_at": "2025-09-16T12:30:00Z", "duration_seconds": 106, "total_size_bytes": 9185231897, "total_files": 5759} + services_data: + type: object + nullable: true + description: Service health status information with errors list + example: {"has_errors": true, "error_count": 1, "services": [{"name": "NethVoice CTI server", "status": "error", "error": "is not running", "since": "2025-09-16T23:10:00Z"}]} + system: + $ref: '#/components/schemas/ApplicationSystemSummary' + organization: + $ref: '#/components/schemas/OrganizationSummary' + created_at: + type: string + format: date-time + description: Record creation timestamp + example: "2025-07-01T09:00:00Z" + last_inventory_at: + type: string + format: date-time + nullable: true + description: Last inventory collection timestamp + example: "2025-07-21T10:25:00Z" + + ApplicationSystemSummary: + type: object + description: Minimal system info for application responses + properties: + id: + type: string + description: System ID + example: "sys_abc123" + name: + type: string + description: System name + example: "Production Cluster" + + ApplicationTotals: + type: object + description: Application statistics + properties: + total: + type: integer + description: Total number of applications + example: 150 + unassigned: + type: integer + description: Number of unassigned applications + example: 25 + assigned: + type: integer + description: Number of assigned applications + example: 125 + with_errors: + type: integer + description: Number of applications with service errors + example: 5 + by_type: + type: object + description: Count by application type + additionalProperties: + type: integer + example: + mail: 30 + webtop: 25 + nethvoice: 20 + nextcloud: 15 + by_status: + type: object + description: Count by status + additionalProperties: + type: integer + example: + assigned: 125 + unassigned: 25 + + ApplicationType: + type: object + description: Application type metadata (only user-facing types are returned) + properties: + instance_of: + type: string + description: Application type identifier (lowercase, e.g., nethvoice, webtop, mail) + example: "nethvoice" + name: + type: string + description: Human-readable name of the application type (e.g., NethVoice, WebTop) + example: "NethVoice" + count: + type: integer + description: Number of applications of this type + example: 30 + + ApplicationTypeSummary: + type: object + description: Applications grouped by type with total count and optional pagination + properties: + total: + type: integer + description: Total number of matching applications (always present, not paginated) + example: 130 + by_type: + type: array + description: Applications grouped by type, sorted and optionally paginated + items: + $ref: '#/components/schemas/ApplicationType' + pagination: + description: Pagination metadata (only present when page_size query param is specified) + $ref: '#/components/schemas/Pagination' + + AssignApplicationRequest: + type: object + required: + - organization_id + properties: + organization_id: + type: string + description: Organization Logto ID to assign to the application (use the logto_id from organization responses) + example: "akkbs6x2wo82" + + UpdateApplicationRequest: + type: object + description: Request to update application notes (other fields are read-only and populated from inventory) + properties: + notes: + type: string + nullable: true + description: Custom notes for the application + example: "Server di posta aziendale per il reparto marketing" + + ImpersonationConsent: + type: object + properties: + id: + type: string + description: Consent ID + example: "consent_123" + user_id: + type: string + description: User ID who grants consent + example: "usr_456" + expires_at: + type: string + format: date-time + description: Consent expiration timestamp + example: "2025-09-04T14:30:00Z" + max_duration_minutes: + type: integer + description: Maximum duration for each impersonation session in minutes + example: 60 + created_at: + type: string + format: date-time + description: Consent creation timestamp + example: "2025-09-03T14:30:00Z" + + ImpersonationSession: + type: object + properties: + session_id: + type: string + description: Unique session identifier + example: "sess_abc123def456" + impersonator_user_id: + type: string + description: User ID of the person doing the impersonation + example: "usr_owner_123" + impersonated_user_id: + type: string + description: User ID of the person being impersonated + example: "usr_target_456" + impersonator_username: + type: string + description: Username of the impersonator + example: "owner@company.com" + impersonated_username: + type: string + description: Username of the impersonated user + example: "customer@example.com" + impersonator_name: + type: string + description: Full name of the impersonator + example: "John Doe" + impersonated_name: + type: string + description: Full name of the impersonated user + example: "Jane Smith" + start_time: + type: string + format: date-time + description: Session start timestamp + example: "2025-09-02T14:30:00Z" + end_time: + type: string + format: date-time + nullable: true + description: Session end timestamp (null if still active) + example: "2025-09-02T15:45:00Z" + duration_minutes: + type: integer + nullable: true + description: Session duration in minutes (null if still active) + example: 75 + action_count: + type: integer + description: Number of actions performed in this session + example: 24 + status: + type: string + enum: [active, completed] + description: Session status + example: "completed" + + ImpersonationAuditEntry: + type: object + properties: + id: + type: string + description: Audit entry ID + example: "audit_xyz789abc" + session_id: + type: string + description: Impersonation session ID + example: "sess_abc123def456" + impersonator_user_id: + type: string + description: User ID of the person doing the impersonation + example: "usr_owner_123" + impersonated_user_id: + type: string + description: User ID of the person being impersonated + example: "usr_target_456" + impersonator_username: + type: string + description: Username of the impersonator + example: "owner@company.com" + impersonated_username: + type: string + description: Username of the impersonated user + example: "customer@example.com" + impersonator_name: + type: string + description: Full name of the impersonator + example: "John Doe" impersonated_name: type: string description: Full name of the impersonated user @@ -4548,10 +5167,249 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ImportConfirmRequest' + $ref: '#/components/schemas/ImportConfirmRequest' + responses: + '200': + description: Import results + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "users imported successfully" + data: + $ref: '#/components/schemas/ImportConfirmResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # =========================================================================== + # THIRD-PARTY APPLICATIONS (from Logto) + # =========================================================================== + /third-party-applications: + get: + operationId: getThirdPartyApplications + tags: + - Backend - Metadata + summary: /third-party-applications - Get third-party applications + description: Get third-party applications filtered by user's organization membership, organization roles, and user roles + responses: + '200': + description: Third-party applications retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "third-party applications retrieved successfully" + data: + type: array + items: + $ref: '#/components/schemas/ThirdPartyApplication' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Failed to fetch applications from Logto + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # =========================================================================== + # AUTHENTICATION + # =========================================================================== + /auth/exchange: + post: + operationId: exchangeToken + tags: + - Backend - Authentication + summary: /auth/exchange - Exchange Logto token for custom JWT + description: Exchange a Logto access token for a custom JWT with embedded permissions + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenExchangeRequest' + responses: + '200': + description: Token exchange successful + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "token exchange successful" + data: + $ref: '#/components/schemas/AuthResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + # =========================================================================== + # USER PROFILE (ME) + # =========================================================================== + /me: + get: + operationId: getMe + tags: + - Backend - Me + summary: /me - Get current user information + description: Get current authenticated user's profile and permissions + responses: + '200': + description: User information retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "user information retrieved successfully" + data: + $ref: '#/components/schemas/UserProfile' + '401': + $ref: '#/components/responses/Unauthorized' + + /me/change-password: + post: + operationId: changeMyPassword + tags: + - Backend - Me + summary: /me/change-password - Change current user password + description: Allow the current authenticated user to change their own password + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangePasswordRequest' + responses: + '200': + description: Password changed successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "password changed successfully" + data: + type: object + nullable: true + example: null + '400': + description: Bad request - validation error for current password verification or new password requirements + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 400 + message: + type: string + example: "validation failed" + data: + type: object + properties: + type: + type: string + enum: [validation_error] + example: "validation_error" + errors: + type: array + items: + type: object + properties: + key: + type: string + description: Field name that failed validation + enum: [current_password, new_password] + example: "new_password" + message: + type: string + description: Validation error type + example: "min_length" + value: + type: string + description: Always empty for security (passwords not exposed) + example: "" + example: + - key: "current_password" + message: "incorrect_password" + value: "" + - key: "new_password" + message: "min_length" + value: "" + - key: "new_password" + message: "missing_uppercase" + value: "" + '401': + description: Unauthorized - invalid or missing JWT token + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "invalid token" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' + + /me/change-info: + post: + operationId: changeMyInfo + tags: + - Backend - Me + summary: /me/change-info - Change current user information + description: Allow the current authenticated user to change their name, email, and phone number. Name and email cannot be empty if provided. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangeInfoRequest' responses: '200': - description: Import results + description: User information changed successfully content: application/json: schema: @@ -4562,29 +5420,13 @@ paths: example: 200 message: type: string - example: "users imported successfully" + example: "user information changed successfully" data: - $ref: '#/components/schemas/ImportConfirmResult' + type: object + nullable: true + example: null '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - # =========================================================================== - # THIRD-PARTY APPLICATIONS (from Logto) - # =========================================================================== - /third-party-applications: - get: - operationId: getThirdPartyApplications - tags: - - Backend - Metadata - summary: /third-party-applications - Get third-party applications - description: Get third-party applications filtered by user's organization membership, organization roles, and user roles - responses: - '200': - description: Third-party applications retrieved successfully + description: Bad request - validation error for name or phone content: application/json: schema: @@ -4592,43 +5434,86 @@ paths: properties: code: type: integer - example: 200 + example: 400 message: type: string - example: "third-party applications retrieved successfully" + example: "validation failed" data: - type: array - items: - $ref: '#/components/schemas/ThirdPartyApplication' + type: object + properties: + type: + type: string + enum: [validation_error] + example: "validation_error" + errors: + type: array + items: + type: object + properties: + key: + type: string + description: Field name that failed validation + enum: [name, email, phone] + example: "name" + message: + type: string + description: Validation error message + enum: ["name cannot be empty", "email cannot be empty", "invalid email format", "invalid phone format"] + example: "name cannot be empty" + value: + type: string + description: Value that failed validation + example: "" + example: + - key: "name" + message: "name cannot be empty" + value: "" '401': - $ref: '#/components/responses/Unauthorized' - '500': - description: Failed to fetch applications from Logto + description: Unauthorized - invalid or missing JWT token content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "invalid token" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' - # =========================================================================== - # AUTHENTICATION - # =========================================================================== - /auth/exchange: - post: - operationId: exchangeToken + /me/avatar: + put: + operationId: uploadMyAvatar tags: - - Backend - Authentication - summary: /auth/exchange - Exchange Logto token for custom JWT - description: Exchange a Logto access token for a custom JWT with embedded permissions - security: [] + - Backend - Me + summary: /me/avatar - Upload current user avatar + description: | + Upload a profile image for the current authenticated user. + The image is validated, resized to 256x256 pixels if larger, re-encoded as PNG, and stored in the database. + The avatar URL is automatically synced to the user's Logto profile. requestBody: required: true content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/TokenExchangeRequest' + type: object + required: + - avatar + properties: + avatar: + type: string + format: binary + description: Avatar image file (PNG, JPEG, or WebP, max 500KB, resized to 256x256) responses: '200': - description: Token exchange successful + description: Avatar uploaded successfully content: application/json: schema: @@ -4639,27 +5524,62 @@ paths: example: 200 message: type: string - example: "token exchange successful" + example: "avatar uploaded successfully" data: - $ref: '#/components/schemas/AuthResponse' + type: object + properties: + avatar_url: + type: string + description: Public URL of the uploaded avatar + example: "https://my.nethesis.it/api/public/users/jf584cz36kce/avatar" '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: deleteMyAvatar + tags: + - Backend - Me + summary: /me/avatar - Delete current user avatar + description: Remove the profile image for the current authenticated user. Clears the avatar from the database and the Logto profile. + responses: + '200': + description: Avatar deleted successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "avatar deleted successfully" + data: + type: object + nullable: true + example: null + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' - # =========================================================================== - # USER PROFILE (ME) - # =========================================================================== - /me: + /me/api-keys: get: - operationId: getMe + operationId: listMyAPIKeys tags: - Backend - Me - summary: /me - Get current user information - description: Get current authenticated user's profile and permissions + summary: /me/api-keys - List current user's API keys + description: | + List the personal API keys owned by the current user. Secrets are never + returned. This endpoint is interactive-session only and cannot be called + with an API key. responses: '200': - description: User information retrieved successfully + description: API keys retrieved successfully content: application/json: schema: @@ -4670,28 +5590,39 @@ paths: example: 200 message: type: string - example: "user information retrieved successfully" + example: "api keys retrieved successfully" data: - $ref: '#/components/schemas/UserProfile' + type: object + properties: + api_keys: + type: array + items: + $ref: '#/components/schemas/APIKey' '401': $ref: '#/components/responses/Unauthorized' - - /me/change-password: + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' post: - operationId: changeMyPassword + operationId: createMyAPIKey tags: - Backend - Me - summary: /me/change-password - Change current user password - description: Allow the current authenticated user to change their own password + summary: /me/api-keys - Create an API key + description: | + Issue a personal API key for non-interactive integrations. The full + plaintext token is returned exactly once and never again. A user may hold + at most 5 active keys. Interactive-session only — cannot be called with an + API key. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/ChangePasswordRequest' + $ref: '#/components/schemas/CreateAPIKeyRequest' responses: - '200': - description: Password changed successfully + '201': + description: API key created successfully content: application/json: schema: @@ -4699,16 +5630,20 @@ paths: properties: code: type: integer - example: 200 + example: 201 message: type: string - example: "password changed successfully" + example: "api key created successfully" data: - type: object - nullable: true - example: null + $ref: '#/components/schemas/CreateAPIKeyResponse' '400': - description: Bad request - validation error for current password verification or new password requirements + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Maximum number of active API keys reached (translatable validation error) content: application/json: schema: @@ -4716,7 +5651,7 @@ paths: properties: code: type: integer - example: 400 + example: 409 message: type: string example: "validation failed" @@ -4734,29 +5669,35 @@ paths: properties: key: type: string - description: Field name that failed validation - enum: [current_password, new_password] - example: "new_password" + example: "limit" message: type: string - description: Validation error type - example: "min_length" - value: - type: string - description: Always empty for security (passwords not exposed) - example: "" - example: - - key: "current_password" - message: "incorrect_password" - value: "" - - key: "new_password" - message: "min_length" - value: "" - - key: "new_password" - message: "missing_uppercase" - value: "" - '401': - description: Unauthorized - invalid or missing JWT token + description: Error code the UI maps to a localized message + example: "max_keys_reached" + '500': + $ref: '#/components/responses/InternalServerError' + + /me/api-keys/{id}: + delete: + operationId: revokeMyAPIKey + tags: + - Backend - Me + summary: /me/api-keys/{id} - Revoke an API key + description: | + Revoke one of the current user's API keys. Revocation is immediate. A user + can only revoke their own keys. Interactive-session only — cannot be called + with an API key. + parameters: + - name: id + in: path + required: true + description: API key ID + schema: + type: string + example: "f47ac10b-58cc-4372-a567-0e02b2c3d479" + responses: + '200': + description: API key revoked successfully content: application/json: schema: @@ -4764,33 +5705,59 @@ paths: properties: code: type: integer - example: 401 + example: 200 message: type: string - example: "invalid token" + example: "api key revoked successfully" data: type: object nullable: true example: null + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /me/change-info: - post: - operationId: changeMyInfo + /me/api-keys/audit: + get: + operationId: listMyAPIKeyAudit tags: - Backend - Me - summary: /me/change-info - Change current user information - description: Allow the current authenticated user to change their name, email, and phone number. Name and email cannot be empty if provided. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ChangeInfoRequest' + summary: /me/api-keys/audit - List current user's API key audit trail + description: | + Audit trail for the current user's API keys: lifecycle events (created, + revoked) and security failures (use of a revoked/expired key, suspended + owner, wrong secret, rate limiting). Successful per-request use is not + recorded here. Interactive-session only — cannot be called with an API key. + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: page_size + in: query + schema: + type: integer + default: 20 + - name: event + in: query + description: Filter by event type + schema: + type: string + enum: [created, revoked, auth_failed, rate_limited] + - name: api_key_id + in: query + description: Filter by a specific key id + schema: + type: string responses: '200': - description: User information changed successfully + description: Audit trail retrieved successfully content: application/json: schema: @@ -4801,13 +5768,72 @@ paths: example: 200 message: type: string - example: "user information changed successfully" + example: "api key audit retrieved successfully" data: type: object - nullable: true - example: null - '400': - description: Bad request - validation error for name or phone + properties: + audit: + type: array + items: + $ref: '#/components/schemas/APIKeyAuditEntry' + pagination: + $ref: '#/components/schemas/Pagination' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + + /public/users/{id}/avatar: + get: + operationId: getPublicAvatar + tags: + - Backend - Public + summary: /public/users/{id}/avatar - Get user avatar image + description: Serves a user's avatar image as binary data. This endpoint is public and requires no authentication. Returns the image with appropriate Content-Type header and browser caching headers. + security: [] + parameters: + - name: id + in: path + required: true + description: User Logto ID (use the logto_id from user responses, not the internal id) or local database ID + schema: + type: string + example: "jf584cz36kce" + responses: + '200': + description: Avatar image binary + headers: + Cache-Control: + schema: + type: string + example: "public, max-age=3600" + content: + image/png: + schema: + type: string + format: binary + '404': + $ref: '#/components/responses/NotFound' + + /auth/refresh: + post: + operationId: refreshToken + tags: + - Backend - Authentication + summary: /auth/refresh - Refresh access token + description: Get new access token using refresh token + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRefreshRequest' + responses: + '200': + description: Token refresh successful content: application/json: schema: @@ -4815,42 +5841,40 @@ paths: properties: code: type: integer - example: 400 + example: 200 message: type: string - example: "validation failed" + example: "token refresh successful" data: - type: object - properties: - type: - type: string - enum: [validation_error] - example: "validation_error" - errors: - type: array - items: - type: object - properties: - key: - type: string - description: Field name that failed validation - enum: [name, email, phone] - example: "name" - message: - type: string - description: Validation error message - enum: ["name cannot be empty", "email cannot be empty", "invalid email format", "invalid phone format"] - example: "name cannot be empty" - value: - type: string - description: Value that failed validation - example: "" - example: - - key: "name" - message: "name cannot be empty" - value: "" + $ref: '#/components/schemas/AuthResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': - description: Unauthorized - invalid or missing JWT token + $ref: '#/components/responses/Unauthorized' + + /auth/logout: + post: + operationId: logout + tags: + - Backend - Authentication + summary: /auth/logout - Logout user + description: Invalidate user's access token and refresh token (blacklist tokens) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + description: Refresh token to blacklist + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + required: + - refresh_token + responses: + '200': + description: Logout successful content: application/json: schema: @@ -4858,43 +5882,56 @@ paths: properties: code: type: integer - example: 401 + example: 200 message: type: string - example: "invalid token" + example: "logout successful" data: type: object nullable: true example: null - '500': - $ref: '#/components/responses/InternalServerError' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' - /me/avatar: - put: - operationId: uploadMyAvatar + # =========================================================================== + # CONSENT-BASED IMPERSONATION SYSTEM + # =========================================================================== + + /impersonate/consent: + post: + operationId: enableImpersonationConsent tags: - - Backend - Me - summary: /me/avatar - Upload current user avatar + - Backend - Impersonation + summary: /impersonate/consent - Enable impersonation consent description: | - Upload a profile image for the current authenticated user. - The image is validated, resized to 256x256 pixels if larger, re-encoded as PNG, and stored in the database. - The avatar URL is automatically synced to the user's Logto profile. + Allows a user to enable consent for being impersonated by authorized users (Super Admin role or Owner organization users). + This is a **privacy-friendly** approach where users explicitly control + if and for how long they can be impersonated. + + **Key Features:** + - User controls their own impersonation consent + - Custom duration (1-168 hours) + - Only active while consent is valid + - Complete audit trail of all impersonation activities requestBody: required: true content: - multipart/form-data: + application/json: schema: type: object - required: - - avatar properties: - avatar: - type: string - format: binary - description: Avatar image file (PNG, JPEG, or WebP, max 500KB, resized to 256x256) + duration_hours: + type: integer + minimum: 1 + maximum: 168 + default: 1 + description: How many hours the consent should be active (max 1 week, defaults to 1 hour) + example: 24 responses: '200': - description: Avatar uploaded successfully + description: Impersonation consent enabled successfully content: application/json: schema: @@ -4905,29 +5942,35 @@ paths: example: 200 message: type: string - example: "avatar uploaded successfully" + example: "impersonation consent enabled successfully" data: type: object properties: - avatar_url: - type: string - description: Public URL of the uploaded avatar - example: "https://my.nethesis.it/api/public/users/jf584cz36kce/avatar" + consent: + $ref: '#/components/schemas/ImpersonationConsent' '400': - $ref: '#/components/responses/BadRequest' + description: Bad request (invalid duration) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: 400 + message: "Duration must be between 1 and 168 hours" '401': $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalServerError' - delete: - operationId: deleteMyAvatar + + get: + operationId: getImpersonationConsentStatus tags: - - Backend - Me - summary: /me/avatar - Delete current user avatar - description: Remove the profile image for the current authenticated user. Clears the avatar from the database and the Logto profile. + - Backend - Impersonation + summary: /impersonate/consent - Get impersonation consent status + description: | + Retrieves the current impersonation consent status for the authenticated user. + Shows if consent is active, when it expires, and other consent details. responses: '200': - description: Avatar deleted successfully + description: Consent status retrieved successfully content: application/json: schema: @@ -4938,29 +5981,30 @@ paths: example: 200 message: type: string - example: "avatar deleted successfully" + example: "consent status retrieved successfully" data: type: object - nullable: true - example: null + properties: + consent: + allOf: + - $ref: '#/components/schemas/ImpersonationConsent' + type: object + nullable: true + description: Consent details (null if no active consent) '401': $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalServerError' - /me/api-keys: - get: - operationId: listMyAPIKeys + delete: + operationId: disableImpersonationConsent tags: - - Backend - Me - summary: /me/api-keys - List current user's API keys + - Backend - Impersonation + summary: /impersonate/consent - Disable impersonation consent description: | - List the personal API keys owned by the current user. Secrets are never - returned. This endpoint is interactive-session only and cannot be called - with an API key. + Disables all active impersonation consent for the authenticated user. + This prevents any future impersonation until consent is re-enabled. responses: '200': - description: API keys retrieved successfully + description: Impersonation consent disabled successfully content: application/json: schema: @@ -4971,39 +6015,39 @@ paths: example: 200 message: type: string - example: "api keys retrieved successfully" + example: "impersonation consent disabled successfully" data: type: object - properties: - api_keys: - type: array - items: - $ref: '#/components/schemas/APIKey' + nullable: true '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '500': - $ref: '#/components/responses/InternalServerError' + + /impersonate: post: - operationId: createMyAPIKey + operationId: impersonateUserWithConsent tags: - - Backend - Me - summary: /me/api-keys - Create an API key + - Backend - Impersonation + summary: /impersonate - Start impersonation (permission-based access) description: | - Issue a personal API key for non-interactive integrations. The full - plaintext token is returned exactly once and never again. A user may hold - at most 5 active keys. Interactive-session only — cannot be called with an - API key. + Allows users with `impersonate:users` permission (Super Admin role) or Owner organization role + to impersonate another user, but only if that user has active consent enabled. + The impersonation token duration will match the user's consent settings. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateAPIKeyRequest' + type: object + properties: + user_id: + type: string + description: Logto ID of the user to impersonate + example: "jf584cz36kce" + required: + - user_id responses: - '201': - description: API key created successfully + '200': + description: Impersonation started successfully content: application/json: schema: @@ -5011,20 +6055,87 @@ paths: properties: code: type: integer - example: 201 + example: 200 message: type: string - example: "api key created successfully" + example: "impersonation started successfully" data: - $ref: '#/components/schemas/CreateAPIKeyResponse' + type: object + properties: + token: + type: string + description: Impersonation JWT token with custom duration + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + expires_in: + type: integer + description: Token expiration time in seconds (matches user consent) + example: 86400 + session_id: + type: string + description: Unique session ID for audit tracking + example: "session-uuid-here" + is_impersonating: + type: boolean + description: Flag indicating impersonation is active + example: true + impersonated_user: + $ref: '#/components/schemas/User' + impersonator: + $ref: '#/components/schemas/User' '400': - $ref: '#/components/responses/BadRequest' + description: Bad request (cannot impersonate yourself, user not found, etc.) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + self_impersonation: + summary: Cannot impersonate yourself + value: + code: 400 + message: "Cannot impersonate yourself" + user_not_found: + summary: Target user not found + value: + code: 400 + message: "Target user not found or inaccessible" + no_consent: + summary: User has no active consent + value: + code: 400 + message: "Target user has not provided consent for impersonation or consent has expired" + '403': + description: Forbidden (insufficient permissions for impersonation, or already impersonating) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + insufficient_permissions: + summary: User without impersonation permissions attempted impersonation + value: + code: 403 + message: "Insufficient permissions to impersonate users" + already_impersonating: + summary: Cannot impersonate while already impersonating + value: + code: 403 + message: "Cannot impersonate while already impersonating another user. Exit current impersonation first." '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': - description: Maximum number of active API keys reached (translatable validation error) + + delete: + operationId: exitImpersonationWithAudit + tags: + - Backend - Impersonation + summary: /impersonate - Exit impersonation mode + description: | + Allows user to exit impersonation mode and return to their original account. + This endpoint can only be called with an active impersonation token. + Returns fresh tokens for the original user and completes the audit session. + responses: + '200': + description: Impersonation ended successfully content: application/json: schema: @@ -5032,53 +6143,70 @@ paths: properties: code: type: integer - example: 409 + example: 200 message: type: string - example: "validation failed" + example: "impersonation ended successfully" data: type: object properties: - type: + token: type: string - enum: [validation_error] - example: "validation_error" - errors: - type: array - items: - type: object - properties: - key: - type: string - example: "limit" - message: - type: string - description: Error code the UI maps to a localized message - example: "max_keys_reached" - '500': - $ref: '#/components/responses/InternalServerError' + description: New JWT token for original user (24-hour expiration) + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + refresh_token: + type: string + description: New refresh token for original user + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + expires_in: + type: integer + description: Token expiration time in seconds (86400 = 24 hours) + example: 86400 + '400': + description: Bad request (not currently impersonating) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: 400 + message: "Not currently impersonating a user" + '401': + $ref: '#/components/responses/Unauthorized' - /me/api-keys/{id}: - delete: - operationId: revokeMyAPIKey + /impersonate/status: + get: + operationId: getImpersonationStatus tags: - - Backend - Me - summary: /me/api-keys/{id} - Revoke an API key + - Backend - Impersonation + summary: /impersonate/status - Check current impersonation status description: | - Revoke one of the current user's API keys. Revocation is immediate. A user - can only revoke their own keys. Interactive-session only — cannot be called - with an API key. - parameters: - - name: id - in: path - required: true - description: API key ID - schema: - type: string - example: "f47ac10b-58cc-4372-a567-0e02b2c3d479" + Checks if the current user has an active impersonation session. + + **Works with both token types:** + - **Impersonation token**: Returns session details from the token + - **Regular token**: Checks Redis for active session and returns a new impersonation token if found + + **Use case for regular token:** + After a page refresh, the frontend loses the impersonation token but calls `/auth/exchange` + to get a regular token. This endpoint allows the frontend to check if there's an active + impersonation session and receive a new impersonation token to continue the session. + + **Response when impersonating:** + - `is_impersonating`: true + - `token`: New impersonation JWT token (with remaining duration) + - `expires_in`: Remaining seconds until expiration + - `session_id`: Session ID for audit tracking + - `impersonated_user`: Details of the user being impersonated + - `impersonator`: Details of the user doing the impersonation + - `expires_at`: Session expiration timestamp + - `created_at`: Session creation timestamp + + **Response when not impersonating:** + - `is_impersonating`: false responses: '200': - description: API key revoked successfully + description: Status retrieved successfully content: application/json: schema: @@ -5089,56 +6217,116 @@ paths: example: 200 message: type: string - example: "api key revoked successfully" + enum: ["currently impersonating", "not currently impersonating"] + example: "currently impersonating" data: type: object - nullable: true - example: null + properties: + is_impersonating: + type: boolean + description: Whether the user is currently impersonating someone + example: true + token: + type: string + description: Impersonation JWT token (only present when is_impersonating is true) + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + expires_in: + type: integer + description: Remaining seconds until token expiration (only present when is_impersonating is true) + example: 3540 + session_id: + type: string + description: Session ID for audit tracking (only present when is_impersonating is true) + example: "session-uuid-here" + impersonated_user: + description: Details of the user being impersonated (only present when is_impersonating is true) + allOf: + - $ref: '#/components/schemas/User' + impersonator: + description: Details of the user doing the impersonation (only present when is_impersonating is true) + allOf: + - $ref: '#/components/schemas/User' + expires_at: + type: string + format: date-time + description: Session expiration timestamp (only present when is_impersonating is true) + example: "2025-09-29T17:33:59Z" + created_at: + type: string + format: date-time + description: Session creation timestamp (only present when is_impersonating is true) + example: "2025-09-29T16:33:59Z" + examples: + impersonating: + summary: Currently impersonating + value: + code: 200 + message: "currently impersonating" + data: + is_impersonating: true + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + expires_in: 3540 + session_id: "d7734c1e-bed3-4350-8992-a5e09d4d253f" + impersonated_user: + id: "d1e17b87-11ce-4e74-a9f8-34d9638135f1" + username: "edoardo_spadoni" + email: "edoardo.spadoni@nethesis.it" + name: "Edoardo Support" + org_role: "Reseller" + impersonator: + id: "c5054f56-3005-43c2-a237-07aa44e1551c" + username: "owner" + email: "owner@example.com" + name: "Company Owner" + org_role: "Owner" + expires_at: "2025-09-29T17:33:59Z" + created_at: "2025-09-29T16:33:59Z" + not_impersonating: + summary: Not impersonating + value: + code: 200 + message: "not currently impersonating" + data: + is_impersonating: false '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' '500': - $ref: '#/components/responses/InternalServerError' + description: Internal server error (failed to check session or generate token) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + session_check_failed: + summary: Failed to check for active session + value: + code: 500 + message: "failed to check impersonation status" + token_generation_failed: + summary: Failed to generate impersonation token + value: + code: 500 + message: "failed to generate impersonation token" - /me/api-keys/audit: + /impersonate/sessions: get: - operationId: listMyAPIKeyAudit + operationId: getImpersonationSessions tags: - - Backend - Me - summary: /me/api-keys/audit - List current user's API key audit trail + - Backend - Impersonation Sessions + summary: /impersonate/sessions - List impersonation sessions description: | - Audit trail for the current user's API keys: lifecycle events (created, - revoked) and security failures (use of a revoked/expired key, suspended - owner, wrong secret, rate limiting). Successful per-request use is not - recorded here. Interactive-session only — cannot be called with an API key. + Retrieves all impersonation sessions for the current user. Sessions are grouped + by session_id and show start/end times, duration, and action count. This endpoint + should be used first to get session overview, then use /impersonate/audit/{session_id} + to get detailed audit logs for specific sessions. parameters: - - name: page - in: query - schema: - type: integer - default: 1 - - name: page_size - in: query - schema: - type: integer - default: 20 - - name: event - in: query - description: Filter by event type - schema: - type: string - enum: [created, revoked, auth_failed, rate_limited] - - name: api_key_id - in: query - description: Filter by a specific key id - schema: - type: string + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + security: + - BearerAuth: [] responses: '200': - description: Audit trail retrieved successfully + description: Sessions retrieved successfully content: application/json: schema: @@ -5149,113 +6337,44 @@ paths: example: 200 message: type: string - example: "api key audit retrieved successfully" + example: "sessions retrieved successfully" data: type: object properties: - audit: + sessions: type: array items: - $ref: '#/components/schemas/APIKeyAuditEntry' + $ref: '#/components/schemas/ImpersonationSession' pagination: $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/InternalServerError' - /public/users/{id}/avatar: + /impersonate/sessions/{session_id}: get: - operationId: getPublicAvatar + operationId: getImpersonationSession tags: - - Backend - Public - summary: /public/users/{id}/avatar - Get user avatar image - description: Serves a user's avatar image as binary data. This endpoint is public and requires no authentication. Returns the image with appropriate Content-Type header and browser caching headers. - security: [] + - Backend - Impersonation Sessions + summary: /impersonate/sessions/{session_id} - Get details for specific session + description: | + Retrieves details for a specific impersonation session. Users can only + view sessions where they were impersonated. The session must belong to + the requesting user. parameters: - - name: id + - name: session_id in: path + description: Impersonation session ID required: true - description: User Logto ID (use the logto_id from user responses, not the internal id) or local database ID schema: type: string - example: "jf584cz36kce" - responses: - '200': - description: Avatar image binary - headers: - Cache-Control: - schema: - type: string - example: "public, max-age=3600" - content: - image/png: - schema: - type: string - format: binary - '404': - $ref: '#/components/responses/NotFound' - - /auth/refresh: - post: - operationId: refreshToken - tags: - - Backend - Authentication - summary: /auth/refresh - Refresh access token - description: Get new access token using refresh token - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefreshRequest' - responses: - '200': - description: Token refresh successful - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "token refresh successful" - data: - $ref: '#/components/schemas/AuthResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - /auth/logout: - post: - operationId: logout - tags: - - Backend - Authentication - summary: /auth/logout - Logout user - description: Invalidate user's access token and refresh token (blacklist tokens) - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - refresh_token: - type: string - description: Refresh token to blacklist - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - required: - - refresh_token + example: "sess_abc123def456" + security: + - BearerAuth: [] responses: '200': - description: Logout successful + description: Session details retrieved successfully content: application/json: schema: @@ -5266,53 +6385,60 @@ paths: example: 200 message: type: string - example: "logout successful" + example: "session details retrieved successfully" data: type: object - nullable: true - example: null + properties: + session: + $ref: '#/components/schemas/ImpersonationSession' '400': - $ref: '#/components/responses/BadRequest' + description: Missing or invalid session_id parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: 400 + message: "session_id parameter is required" '401': $ref: '#/components/responses/Unauthorized' + '404': + description: Session not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: 404 + message: "session not found" + '500': + $ref: '#/components/responses/InternalServerError' - # =========================================================================== - # CONSENT-BASED IMPERSONATION SYSTEM - # =========================================================================== - - /impersonate/consent: - post: - operationId: enableImpersonationConsent + /impersonate/sessions/{session_id}/audit: + get: + operationId: getSessionAudit tags: - - Backend - Impersonation - summary: /impersonate/consent - Enable impersonation consent + - Backend - Impersonation Sessions + summary: /impersonate/sessions/{session_id}/audit - Get audit logs for specific session description: | - Allows a user to enable consent for being impersonated by authorized users (Super Admin role or Owner organization users). - This is a **privacy-friendly** approach where users explicitly control - if and for how long they can be impersonated. - - **Key Features:** - - User controls their own impersonation consent - - Custom duration (1-168 hours) - - Only active while consent is valid - - Complete audit trail of all impersonation activities - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - duration_hours: - type: integer - minimum: 1 - maximum: 168 - default: 1 - description: How many hours the consent should be active (max 1 week, defaults to 1 hour) - example: 24 + Retrieves detailed audit logs for a specific impersonation session. Users can only + view audit logs for their own sessions (when they were impersonated). The session + must belong to the requesting user. + parameters: + - name: session_id + in: path + description: Impersonation session ID + required: true + schema: + type: string + example: "sess_abc123def456" + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + security: + - BearerAuth: [] responses: '200': - description: Impersonation consent enabled successfully + description: Session audit logs retrieved successfully content: application/json: schema: @@ -5323,35 +6449,66 @@ paths: example: 200 message: type: string - example: "impersonation consent enabled successfully" + example: "session audit retrieved successfully" data: type: object properties: - consent: - $ref: '#/components/schemas/ImpersonationConsent' + session_id: + type: string + description: Session ID + example: "sess_abc123def456" + entries: + type: array + items: + $ref: '#/components/schemas/ImpersonationAuditEntry' + pagination: + $ref: '#/components/schemas/Pagination' '400': - description: Bad request (invalid duration) + description: Missing or invalid session_id parameter content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: code: 400 - message: "Duration must be between 1 and 168 hours" + message: "session_id parameter is required" '401': $ref: '#/components/responses/Unauthorized' + '404': + description: Session not found or access denied + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: 404 + message: "session not found or access denied" + '500': + $ref: '#/components/responses/InternalServerError' + # =========================================================================== + # BUSINESS HIERARCHY - CUSTOMERS + # =========================================================================== + /customers: get: - operationId: getImpersonationConsentStatus + operationId: getCustomers tags: - - Backend - Impersonation - summary: /impersonate/consent - Get impersonation consent status - description: | - Retrieves the current impersonation consent status for the authenticated user. - Shows if consent is active, when it expires, and other consent details. + - Backend - Customers + summary: /customers - List customers + description: Get paginated list of customers (Owner + Distributor + Reseller) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/CustomerSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + - $ref: '#/components/parameters/OrganizationStatusFilterParam' + - $ref: '#/components/parameters/OrgCreatedByFilterParam' + - $ref: '#/components/parameters/OwnedByOrganizationFilterParam' + - $ref: '#/components/parameters/IncludeHierarchyParam' responses: '200': - description: Consent status retrieved successfully + description: Customers retrieved successfully content: application/json: schema: @@ -5362,30 +6519,76 @@ paths: example: 200 message: type: string - example: "consent status retrieved successfully" + example: "customers retrieved successfully" data: type: object properties: - consent: - allOf: - - $ref: '#/components/schemas/ImpersonationConsent' - type: object - nullable: true - description: Consent details (null if no active consent) + customers: + type: array + items: + $ref: '#/components/schemas/OrganizationWithStats' + pagination: + $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' - delete: - operationId: disableImpersonationConsent + post: + operationId: createCustomer tags: - - Backend - Impersonation - summary: /impersonate/consent - Disable impersonation consent - description: | - Disables all active impersonation consent for the authenticated user. - This prevents any future impersonation until consent is re-enabled. + - Backend - Customers + summary: /customers - Create customer + description: Create a new customer organization (Owner + Distributor + Reseller) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationInput' + responses: + '201': + description: Customer created successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "customer created successfully" + data: + $ref: '#/components/schemas/Organization' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/UnprocessableEntity' + + /customers/{id}: + get: + operationId: getCustomerById + tags: + - Backend - Customers + summary: /customers/{id} - Get single customer + description: Get a specific customer by ID (Owner + Distributor + Reseller) + parameters: + - name: id + in: path + required: true + description: Customer Logto ID + schema: + type: string + example: "jf584cz36kce" responses: '200': - description: Impersonation consent disabled successfully + description: Customer retrieved successfully content: application/json: schema: @@ -5396,39 +6599,39 @@ paths: example: 200 message: type: string - example: "impersonation consent disabled successfully" + example: "customer retrieved successfully" data: - type: object - nullable: true + $ref: '#/components/schemas/Organization' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /impersonate: - post: - operationId: impersonateUserWithConsent + put: + operationId: updateCustomer tags: - - Backend - Impersonation - summary: /impersonate - Start impersonation (permission-based access) - description: | - Allows users with `impersonate:users` permission (Super Admin role) or Owner organization role - to impersonate another user, but only if that user has active consent enabled. - The impersonation token duration will match the user's consent settings. - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user_id: - type: string - description: Logto ID of the user to impersonate - example: "jf584cz36kce" - required: - - user_id + - Backend - Customers + summary: /customers/{id} - Update customer + description: Update a customer organization (Owner + Distributor + Reseller) + parameters: + - name: id + in: path + required: true + description: Customer Logto ID + schema: + type: string + example: "jf584cz36kce" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationInput' responses: '200': - description: Impersonation started successfully + description: Customer updated successfully content: application/json: schema: @@ -5439,84 +6642,37 @@ paths: example: 200 message: type: string - example: "impersonation started successfully" + example: "customer updated successfully" data: - type: object - properties: - token: - type: string - description: Impersonation JWT token with custom duration - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - expires_in: - type: integer - description: Token expiration time in seconds (matches user consent) - example: 86400 - session_id: - type: string - description: Unique session ID for audit tracking - example: "session-uuid-here" - is_impersonating: - type: boolean - description: Flag indicating impersonation is active - example: true - impersonated_user: - $ref: '#/components/schemas/User' - impersonator: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/Organization' '400': - description: Bad request (cannot impersonate yourself, user not found, etc.) - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - examples: - self_impersonation: - summary: Cannot impersonate yourself - value: - code: 400 - message: "Cannot impersonate yourself" - user_not_found: - summary: Target user not found - value: - code: 400 - message: "Target user not found or inaccessible" - no_consent: - summary: User has no active consent - value: - code: 400 - message: "Target user has not provided consent for impersonation or consent has expired" - '403': - description: Forbidden (insufficient permissions for impersonation, or already impersonating) - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - examples: - insufficient_permissions: - summary: User without impersonation permissions attempted impersonation - value: - code: 403 - message: "Insufficient permissions to impersonate users" - already_impersonating: - summary: Cannot impersonate while already impersonating - value: - code: 403 - message: "Cannot impersonate while already impersonating another user. Exit current impersonation first." + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' delete: - operationId: exitImpersonationWithAudit + operationId: deleteCustomer tags: - - Backend - Impersonation - summary: /impersonate - Exit impersonation mode - description: | - Allows user to exit impersonation mode and return to their original account. - This endpoint can only be called with an active impersonation token. - Returns fresh tokens for the original user and completes the audit session. + - Backend - Customers + summary: /customers/{id} - Delete customer + description: Delete a customer organization (Owner + Distributor + Reseller) + parameters: + - name: id + in: path + required: true + description: Customer Logto ID + schema: + type: string + example: "jf584cz36kce" responses: '200': - description: Impersonation ended successfully + description: Customer deleted successfully content: application/json: schema: @@ -5527,67 +6683,44 @@ paths: example: 200 message: type: string - example: "impersonation ended successfully" + example: "customer deleted successfully" data: type: object properties: - token: - type: string - description: New JWT token for original user (24-hour expiration) - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - refresh_token: - type: string - description: New refresh token for original user - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - expires_in: + deleted_systems_count: type: integer - description: Token expiration time in seconds (86400 = 24 hours) - example: 86400 + description: Number of systems cascade soft-deleted for this customer + example: 2 + deleted_users_count: + type: integer + description: Number of users cascade soft-deleted for this customer + example: 3 '400': - description: Bad request (not currently impersonating) - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - code: 400 - message: "Not currently impersonating a user" + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /impersonate/status: + /customers/{id}/stats: get: - operationId: getImpersonationStatus + operationId: getCustomerStats tags: - - Backend - Impersonation - summary: /impersonate/status - Check current impersonation status - description: | - Checks if the current user has an active impersonation session. - - **Works with both token types:** - - **Impersonation token**: Returns session details from the token - - **Regular token**: Checks Redis for active session and returns a new impersonation token if found - - **Use case for regular token:** - After a page refresh, the frontend loses the impersonation token but calls `/auth/exchange` - to get a regular token. This endpoint allows the frontend to check if there's an active - impersonation session and receive a new impersonation token to continue the session. - - **Response when impersonating:** - - `is_impersonating`: true - - `token`: New impersonation JWT token (with remaining duration) - - `expires_in`: Remaining seconds until expiration - - `session_id`: Session ID for audit tracking - - `impersonated_user`: Details of the user being impersonated - - `impersonator`: Details of the user doing the impersonation - - `expires_at`: Session expiration timestamp - - `created_at`: Session creation timestamp - - **Response when not impersonating:** - - `is_impersonating`: false + - Backend - Customers + summary: /customers/{id}/stats - Get customer statistics + description: Get users, systems and applications count for a specific customer (Owner + Distributor + Reseller + own Customer) + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Customer Logto ID responses: '200': - description: Status retrieved successfully + description: Customer stats retrieved successfully content: application/json: schema: @@ -5598,116 +6731,85 @@ paths: example: 200 message: type: string - enum: ["currently impersonating", "not currently impersonating"] - example: "currently impersonating" + example: "customer stats retrieved successfully" data: - type: object - properties: - is_impersonating: - type: boolean - description: Whether the user is currently impersonating someone - example: true - token: - type: string - description: Impersonation JWT token (only present when is_impersonating is true) - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - expires_in: - type: integer - description: Remaining seconds until token expiration (only present when is_impersonating is true) - example: 3540 - session_id: - type: string - description: Session ID for audit tracking (only present when is_impersonating is true) - example: "session-uuid-here" - impersonated_user: - description: Details of the user being impersonated (only present when is_impersonating is true) - allOf: - - $ref: '#/components/schemas/User' - impersonator: - description: Details of the user doing the impersonation (only present when is_impersonating is true) - allOf: - - $ref: '#/components/schemas/User' - expires_at: - type: string - format: date-time - description: Session expiration timestamp (only present when is_impersonating is true) - example: "2025-09-29T17:33:59Z" - created_at: - type: string - format: date-time - description: Session creation timestamp (only present when is_impersonating is true) - example: "2025-09-29T16:33:59Z" - examples: - impersonating: - summary: Currently impersonating - value: - code: 200 - message: "currently impersonating" - data: - is_impersonating: true - token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - expires_in: 3540 - session_id: "d7734c1e-bed3-4350-8992-a5e09d4d253f" - impersonated_user: - id: "d1e17b87-11ce-4e74-a9f8-34d9638135f1" - username: "edoardo_spadoni" - email: "edoardo.spadoni@nethesis.it" - name: "Edoardo Support" - org_role: "Reseller" - impersonator: - id: "c5054f56-3005-43c2-a237-07aa44e1551c" - username: "owner" - email: "owner@example.com" - name: "Company Owner" - org_role: "Owner" - expires_at: "2025-09-29T17:33:59Z" - created_at: "2025-09-29T16:33:59Z" - not_impersonating: - summary: Not impersonating - value: - code: 200 - message: "not currently impersonating" - data: - is_impersonating: false + $ref: '#/components/schemas/CustomerStats' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '500': - description: Internal server error (failed to check session or generate token) + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /customers/{id}/suspend: + patch: + operationId: suspendCustomer + tags: + - Backend - Customers + summary: /customers/{id}/suspend - Suspend customer + description: Suspend a customer and all its users (cascade suspension). Owner, Distributor, and Reseller (hierarchical) can suspend. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Customer Logto ID + responses: + '200': + description: Customer suspended successfully content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - examples: - session_check_failed: - summary: Failed to check for active session - value: - code: 500 - message: "failed to check impersonation status" - token_generation_failed: - summary: Failed to generate impersonation token - value: - code: 500 - message: "failed to generate impersonation token" + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "customer suspended successfully" + data: + type: object + properties: + customer: + $ref: '#/components/schemas/Organization' + suspended_users_count: + type: integer + example: 5 + description: Number of users that were cascade-suspended + suspended_systems_count: + type: integer + example: 3 + description: Number of systems that were cascade-suspended + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /impersonate/sessions: - get: - operationId: getImpersonationSessions + /customers/{id}/reactivate: + patch: + operationId: reactivateCustomer tags: - - Backend - Impersonation Sessions - summary: /impersonate/sessions - List impersonation sessions - description: | - Retrieves all impersonation sessions for the current user. Sessions are grouped - by session_id and show start/end times, duration, and action count. This endpoint - should be used first to get session overview, then use /impersonate/audit/{session_id} - to get detailed audit logs for specific sessions. + - Backend - Customers + summary: /customers/{id}/reactivate - Reactivate customer + description: Reactivate a suspended customer and all its cascade-suspended users. Owner, Distributor, and Reseller (hierarchical) can reactivate. parameters: - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - security: - - BearerAuth: [] + - name: id + in: path + required: true + schema: + type: string + description: Customer Logto ID responses: '200': - description: Sessions retrieved successfully + description: Customer reactivated successfully content: application/json: schema: @@ -5718,44 +6820,47 @@ paths: example: 200 message: type: string - example: "sessions retrieved successfully" + example: "customer reactivated successfully" data: type: object properties: - sessions: - type: array - items: - $ref: '#/components/schemas/ImpersonationSession' - pagination: - $ref: '#/components/schemas/Pagination' + customer: + $ref: '#/components/schemas/Organization' + reactivated_users_count: + type: integer + example: 5 + description: Number of users that were cascade-reactivated + reactivated_systems_count: + type: integer + example: 3 + description: Number of systems that were cascade-reactivated + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalServerError' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /impersonate/sessions/{session_id}: - get: - operationId: getImpersonationSession + /customers/{id}/restore: + patch: + operationId: restoreCustomer tags: - - Backend - Impersonation Sessions - summary: /impersonate/sessions/{session_id} - Get details for specific session - description: | - Retrieves details for a specific impersonation session. Users can only - view sessions where they were impersonated. The session must belong to - the requesting user. + - Backend - Customers + summary: /customers/{id}/restore - Restore soft-deleted customer + description: Restore a soft-deleted customer (requires manage:customers permission and hierarchical validation) parameters: - - name: session_id + - name: id in: path - description: Impersonation session ID required: true + description: Customer Logto ID schema: type: string - example: "sess_abc123def456" - security: - - BearerAuth: [] + example: "jf584cz36kce" responses: '200': - description: Session details retrieved successfully + description: Customer restored successfully content: application/json: schema: @@ -5766,60 +6871,120 @@ paths: example: 200 message: type: string - example: "session details retrieved successfully" + example: "customer restored successfully" data: type: object properties: - session: - $ref: '#/components/schemas/ImpersonationSession' + restored_systems_count: + type: integer + description: Number of systems cascade restored for this customer + example: 2 + restored_users_count: + type: integer + description: Number of users cascade restored for this customer + example: 3 '400': - description: Missing or invalid session_id parameter + description: Bad request - Customer is not deleted content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - example: - code: 400 - message: "session_id parameter is required" + type: object + properties: + code: + type: integer + example: 400 + message: + type: string + example: "customer is not deleted" + data: + type: object + nullable: true '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': - description: Session not found - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - code: 404 - message: "session not found" - '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/NotFound' - /impersonate/sessions/{session_id}/audit: - get: - operationId: getSessionAudit + /customers/{id}/destroy: + delete: + operationId: destroyCustomer tags: - - Backend - Impersonation Sessions - summary: /impersonate/sessions/{session_id}/audit - Get audit logs for specific session - description: | - Retrieves detailed audit logs for a specific impersonation session. Users can only - view audit logs for their own sessions (when they were impersonated). The session - must belong to the requesting user. + - Backend - Customers + summary: /customers/{id}/destroy - Permanently delete customer + description: Permanently delete a customer organization (requires destroy:customers permission and hierarchical validation) parameters: - - name: session_id + - name: id in: path - description: Impersonation session ID required: true + description: Customer Logto ID schema: type: string - example: "sess_abc123def456" - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - security: - - BearerAuth: [] + example: "jf584cz36kce" + responses: + '200': + description: Customer permanently destroyed + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "customer permanently destroyed" + data: + type: object + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /customers/totals: + get: + operationId: getCustomersTotals + tags: + - Backend - Customers Stats + summary: /customers/totals - Get customers totals + description: Get total count of customers accessible to the user (Owner + Distributor + Reseller) + responses: + '200': + description: Customers totals retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "customers totals retrieved" + data: + $ref: '#/components/schemas/Totals' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /customers/trend: + get: + operationId: getCustomersTrend + tags: + - Backend - Customers Stats + summary: /customers/trend - Get customers trend data + description: Get trend analysis of customers over time with cumulative counts (Owner + Distributor + Reseller) + parameters: + - $ref: '#/components/parameters/TrendPeriodParam' responses: '200': - description: Session audit logs retrieved successfully + description: Customers trend data retrieved successfully content: application/json: schema: @@ -5830,58 +6995,69 @@ paths: example: 200 message: type: string - example: "session audit retrieved successfully" + example: "customers trend retrieved successfully" data: - type: object - properties: - session_id: - type: string - description: Session ID - example: "sess_abc123def456" - entries: - type: array - items: - $ref: '#/components/schemas/ImpersonationAuditEntry' - pagination: - $ref: '#/components/schemas/Pagination' + $ref: '#/components/schemas/TrendResponse' '400': - description: Missing or invalid session_id parameter - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - code: 400 - message: "session_id parameter is required" + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '404': - description: Session not found or access denied + '403': + $ref: '#/components/responses/Forbidden' + + /customers/export: + get: + operationId: exportCustomers + tags: + - Backend - Customers Import/Export + summary: /customers/export - Export customers to CSV or PDF + description: Export customers to CSV or PDF format with applied filters (max 10,000 customers) + parameters: + - name: format + in: query + required: true + description: Export format (csv or pdf) + schema: + type: string + enum: [csv, pdf] + example: "csv" + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/CustomerSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + responses: + '200': + description: Customers exported successfully content: - application/json: + text/csv: schema: - $ref: '#/components/schemas/ErrorResponse' - example: - code: 404 - message: "session not found or access denied" - '500': - $ref: '#/components/responses/InternalServerError' + type: string + format: binary + application/pdf: + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' # =========================================================================== - # BUSINESS HIERARCHY - CUSTOMERS + # BUSINESS HIERARCHY - DISTRIBUTORS # =========================================================================== - /customers: + /distributors: get: - operationId: getCustomers + operationId: getDistributors tags: - - Backend - Customers - summary: /customers - List customers - description: Get paginated list of customers (Owner + Distributor + Reseller) + - Backend - Distributors + summary: /distributors - List distributors + description: Get paginated list of distributors (Owner only) parameters: - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PageSizeParam' - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/CustomerSortByParam' + - $ref: '#/components/parameters/DistributorSortByParam' - $ref: '#/components/parameters/SortDirectionParam' - $ref: '#/components/parameters/OrganizationStatusFilterParam' - $ref: '#/components/parameters/OrgCreatedByFilterParam' @@ -5889,7 +7065,7 @@ paths: - $ref: '#/components/parameters/IncludeHierarchyParam' responses: '200': - description: Customers retrieved successfully + description: Distributors retrieved successfully content: application/json: schema: @@ -5900,11 +7076,11 @@ paths: example: 200 message: type: string - example: "customers retrieved successfully" + example: "distributors retrieved successfully" data: type: object properties: - customers: + distributors: type: array items: $ref: '#/components/schemas/OrganizationWithStats' @@ -5916,11 +7092,11 @@ paths: $ref: '#/components/responses/Forbidden' post: - operationId: createCustomer + operationId: createDistributor tags: - - Backend - Customers - summary: /customers - Create customer - description: Create a new customer organization (Owner + Distributor + Reseller) + - Backend - Distributors + summary: /distributors - Create distributor + description: Create a new distributor organization (Owner only) requestBody: required: true content: @@ -5929,7 +7105,7 @@ paths: $ref: '#/components/schemas/OrganizationInput' responses: '201': - description: Customer created successfully + description: Distributor created successfully content: application/json: schema: @@ -5940,7 +7116,7 @@ paths: example: 201 message: type: string - example: "customer created successfully" + example: "distributor created successfully" data: $ref: '#/components/schemas/Organization' '400': @@ -5952,24 +7128,24 @@ paths: '422': $ref: '#/components/responses/UnprocessableEntity' - /customers/{id}: + /distributors/{id}: get: - operationId: getCustomerById + operationId: getDistributorById tags: - - Backend - Customers - summary: /customers/{id} - Get single customer - description: Get a specific customer by ID (Owner + Distributor + Reseller) + - Backend - Distributors + summary: /distributors/{id} - Get single distributor + description: Get a specific distributor by ID (Owner only) parameters: - name: id in: path required: true - description: Customer Logto ID + description: Distributor Logto ID schema: type: string example: "jf584cz36kce" responses: '200': - description: Customer retrieved successfully + description: Distributor retrieved successfully content: application/json: schema: @@ -5980,7 +7156,7 @@ paths: example: 200 message: type: string - example: "customer retrieved successfully" + example: "distributor retrieved successfully" data: $ref: '#/components/schemas/Organization' '401': @@ -5991,16 +7167,16 @@ paths: $ref: '#/components/responses/NotFound' put: - operationId: updateCustomer + operationId: updateDistributor tags: - - Backend - Customers - summary: /customers/{id} - Update customer - description: Update a customer organization (Owner + Distributor + Reseller) + - Backend - Distributors + summary: /distributors/{id} - Update distributor + description: Update a distributor organization (Owner only) parameters: - name: id in: path required: true - description: Customer Logto ID + description: Distributor Logto ID schema: type: string example: "jf584cz36kce" @@ -6012,7 +7188,7 @@ paths: $ref: '#/components/schemas/OrganizationInput' responses: '200': - description: Customer updated successfully + description: Distributor updated successfully content: application/json: schema: @@ -6023,7 +7199,7 @@ paths: example: 200 message: type: string - example: "customer updated successfully" + example: "distributor updated successfully" data: $ref: '#/components/schemas/Organization' '400': @@ -6038,22 +7214,22 @@ paths: $ref: '#/components/responses/UnprocessableEntity' delete: - operationId: deleteCustomer + operationId: deleteDistributor tags: - - Backend - Customers - summary: /customers/{id} - Delete customer - description: Delete a customer organization (Owner + Distributor + Reseller) + - Backend - Distributors + summary: /distributors/{id} - Delete distributor + description: Delete a distributor organization (Owner only) parameters: - name: id in: path required: true - description: Customer Logto ID + description: Distributor Logto ID schema: type: string example: "jf584cz36kce" responses: '200': - description: Customer deleted successfully + description: Distributor deleted successfully content: application/json: schema: @@ -6064,18 +7240,18 @@ paths: example: 200 message: type: string - example: "customer deleted successfully" + example: "distributor deleted successfully" data: type: object properties: deleted_systems_count: type: integer - description: Number of systems cascade soft-deleted for this customer - example: 2 + description: Number of systems cascade soft-deleted across the distributor hierarchy + example: 5 deleted_users_count: type: integer - description: Number of users cascade soft-deleted for this customer - example: 3 + description: Number of users cascade soft-deleted across the distributor hierarchy + example: 12 '400': $ref: '#/components/responses/BadRequest' '401': @@ -6085,23 +7261,23 @@ paths: '404': $ref: '#/components/responses/NotFound' - /customers/{id}/stats: + /distributors/{id}/stats: get: - operationId: getCustomerStats + operationId: getDistributorStats tags: - - Backend - Customers - summary: /customers/{id}/stats - Get customer statistics - description: Get users, systems and applications count for a specific customer (Owner + Distributor + Reseller + own Customer) + - Backend - Distributors + summary: /distributors/{id}/stats - Get distributor statistics + description: Get users, systems, resellers and customers count for a specific distributor (Owner only) parameters: - name: id in: path required: true schema: type: string - description: Customer Logto ID + description: Distributor Logto ID responses: '200': - description: Customer stats retrieved successfully + description: Distributor stats retrieved successfully content: application/json: schema: @@ -6112,9 +7288,9 @@ paths: example: 200 message: type: string - example: "customer stats retrieved successfully" + example: "distributor stats retrieved successfully" data: - $ref: '#/components/schemas/CustomerStats' + $ref: '#/components/schemas/DistributorStats' '400': $ref: '#/components/responses/BadRequest' '401': @@ -6124,23 +7300,23 @@ paths: '404': $ref: '#/components/responses/NotFound' - /customers/{id}/suspend: + /distributors/{id}/suspend: patch: - operationId: suspendCustomer + operationId: suspendDistributor tags: - - Backend - Customers - summary: /customers/{id}/suspend - Suspend customer - description: Suspend a customer and all its users (cascade suspension). Owner, Distributor, and Reseller (hierarchical) can suspend. + - Backend - Distributors + summary: /distributors/{id}/suspend - Suspend distributor + description: Suspend a distributor and all its users (cascade suspension). Owner only. parameters: - name: id in: path required: true schema: type: string - description: Customer Logto ID + description: Distributor Logto ID responses: '200': - description: Customer suspended successfully + description: Distributor suspended successfully content: application/json: schema: @@ -6151,19 +7327,27 @@ paths: example: 200 message: type: string - example: "customer suspended successfully" + example: "distributor suspended successfully" data: type: object properties: - customer: + distributor: $ref: '#/components/schemas/Organization' + suspended_resellers_count: + type: integer + example: 3 + description: Number of resellers that were cascade-suspended + suspended_customers_count: + type: integer + example: 10 + description: Number of customers that were cascade-suspended suspended_users_count: type: integer - example: 5 + example: 25 description: Number of users that were cascade-suspended suspended_systems_count: type: integer - example: 3 + example: 15 description: Number of systems that were cascade-suspended '400': $ref: '#/components/responses/BadRequest' @@ -6174,23 +7358,23 @@ paths: '404': $ref: '#/components/responses/NotFound' - /customers/{id}/reactivate: + /distributors/{id}/reactivate: patch: - operationId: reactivateCustomer + operationId: reactivateDistributor tags: - - Backend - Customers - summary: /customers/{id}/reactivate - Reactivate customer - description: Reactivate a suspended customer and all its cascade-suspended users. Owner, Distributor, and Reseller (hierarchical) can reactivate. + - Backend - Distributors + summary: /distributors/{id}/reactivate - Reactivate distributor + description: Reactivate a suspended distributor and all its cascade-suspended users. Owner only. parameters: - name: id in: path required: true schema: type: string - description: Customer Logto ID + description: Distributor Logto ID responses: '200': - description: Customer reactivated successfully + description: Distributor reactivated successfully content: application/json: schema: @@ -6201,19 +7385,27 @@ paths: example: 200 message: type: string - example: "customer reactivated successfully" + example: "distributor reactivated successfully" data: type: object properties: - customer: + distributor: $ref: '#/components/schemas/Organization' + reactivated_resellers_count: + type: integer + example: 3 + description: Number of resellers that were cascade-reactivated + reactivated_customers_count: + type: integer + example: 10 + description: Number of customers that were cascade-reactivated reactivated_users_count: type: integer - example: 5 + example: 25 description: Number of users that were cascade-reactivated reactivated_systems_count: type: integer - example: 3 + example: 15 description: Number of systems that were cascade-reactivated '400': $ref: '#/components/responses/BadRequest' @@ -6224,24 +7416,24 @@ paths: '404': $ref: '#/components/responses/NotFound' - /customers/{id}/restore: + /distributors/{id}/restore: patch: - operationId: restoreCustomer + operationId: restoreDistributor tags: - - Backend - Customers - summary: /customers/{id}/restore - Restore soft-deleted customer - description: Restore a soft-deleted customer (requires manage:customers permission and hierarchical validation) + - Backend - Distributors + summary: /distributors/{id}/restore - Restore soft-deleted distributor + description: Restore a soft-deleted distributor (requires manage:distributors permission and hierarchical validation) parameters: - name: id in: path required: true - description: Customer Logto ID + description: Distributor Logto ID schema: type: string example: "jf584cz36kce" responses: '200': - description: Customer restored successfully + description: Distributor restored successfully content: application/json: schema: @@ -6252,20 +7444,20 @@ paths: example: 200 message: type: string - example: "customer restored successfully" + example: "distributor restored successfully" data: type: object properties: restored_systems_count: type: integer - description: Number of systems cascade restored for this customer - example: 2 + description: Number of systems cascade restored across the distributor hierarchy + example: 5 restored_users_count: type: integer - description: Number of users cascade restored for this customer - example: 3 + description: Number of users cascade restored across the distributor hierarchy + example: 12 '400': - description: Bad request - Customer is not deleted + description: Bad request - Distributor is not deleted content: application/json: schema: @@ -6276,7 +7468,7 @@ paths: example: 400 message: type: string - example: "customer is not deleted" + example: "distributor is not deleted" data: type: object nullable: true @@ -6287,24 +7479,24 @@ paths: '404': $ref: '#/components/responses/NotFound' - /customers/{id}/destroy: + /distributors/{id}/destroy: delete: - operationId: destroyCustomer + operationId: destroyDistributor tags: - - Backend - Customers - summary: /customers/{id}/destroy - Permanently delete customer - description: Permanently delete a customer organization (requires destroy:customers permission and hierarchical validation) + - Backend - Distributors + summary: /distributors/{id}/destroy - Permanently delete distributor + description: Permanently delete a distributor organization (requires destroy:distributors permission and hierarchical validation) parameters: - name: id in: path required: true - description: Customer Logto ID + description: Distributor Logto ID schema: type: string example: "jf584cz36kce" responses: '200': - description: Customer permanently destroyed + description: Distributor permanently destroyed content: application/json: schema: @@ -6315,7 +7507,7 @@ paths: example: 200 message: type: string - example: "customer permanently destroyed" + example: "distributor permanently destroyed" data: type: object nullable: true @@ -6326,16 +7518,16 @@ paths: '404': $ref: '#/components/responses/NotFound' - /customers/totals: + /distributors/totals: get: - operationId: getCustomersTotals + operationId: getDistributorsTotals tags: - - Backend - Customers Stats - summary: /customers/totals - Get customers totals - description: Get total count of customers accessible to the user (Owner + Distributor + Reseller) + - Backend - Distributors Stats + summary: /distributors/totals - Get distributors totals + description: Get total count of distributors accessible to the user (Owner only) responses: '200': - description: Customers totals retrieved successfully + description: Distributors totals retrieved successfully content: application/json: schema: @@ -6346,7 +7538,7 @@ paths: example: 200 message: type: string - example: "customers totals retrieved" + example: "distributors totals retrieved" data: $ref: '#/components/schemas/Totals' '401': @@ -6354,18 +7546,18 @@ paths: '403': $ref: '#/components/responses/Forbidden' - /customers/trend: + /distributors/trend: get: - operationId: getCustomersTrend + operationId: getDistributorsTrend tags: - - Backend - Customers Stats - summary: /customers/trend - Get customers trend data - description: Get trend analysis of customers over time with cumulative counts (Owner + Distributor + Reseller) + - Backend - Distributors Stats + summary: /distributors/trend - Get distributors trend data + description: Get trend analysis of distributors over time with cumulative counts (Owner only) parameters: - $ref: '#/components/parameters/TrendPeriodParam' responses: '200': - description: Customers trend data retrieved successfully + description: Distributors trend data retrieved successfully content: application/json: schema: @@ -6376,7 +7568,7 @@ paths: example: 200 message: type: string - example: "customers trend retrieved successfully" + example: "distributors trend retrieved successfully" data: $ref: '#/components/schemas/TrendResponse' '400': @@ -6386,13 +7578,13 @@ paths: '403': $ref: '#/components/responses/Forbidden' - /customers/export: + /distributors/export: get: - operationId: exportCustomers + operationId: exportDistributors tags: - - Backend - Customers Import/Export - summary: /customers/export - Export customers to CSV or PDF - description: Export customers to CSV or PDF format with applied filters (max 10,000 customers) + - Backend - Distributors Import/Export + summary: /distributors/export - Export distributors to CSV or PDF + description: Export distributors to CSV or PDF format with applied filters (max 10,000 distributors) parameters: - name: format in: query @@ -6403,11 +7595,11 @@ paths: enum: [csv, pdf] example: "csv" - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/CustomerSortByParam' + - $ref: '#/components/parameters/DistributorSortByParam' - $ref: '#/components/parameters/SortDirectionParam' responses: '200': - description: Customers exported successfully + description: Distributors exported successfully content: text/csv: schema: @@ -6417,36 +7609,247 @@ paths: schema: type: string format: binary - '400': - $ref: '#/components/responses/BadRequest' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # =========================================================================== + # DISTRIBUTORS IMPORT + # =========================================================================== + /distributors/import/template: + get: + operationId: getDistributorsImportTemplate + tags: + - Backend - Distributors Import/Export + summary: /distributors/import/template - Download CSV import template + description: | + Download a UTF-8 CSV file pre-populated with the expected header row and a few example + data rows for the distributor import flow. Columns: `company_name`, `description`, `vat_number`, + `address`, `city`, `main_contact`, `email`, `phone`, `language`, `notes`. Sent as a + `text/csv` attachment with `Content-Disposition: attachment; filename="distributors_import_template.csv"`. + responses: + '200': + description: CSV template downloaded + content: + text/csv: + schema: + type: string + format: binary + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /distributors/import/validate: + post: + operationId: validateDistributorsImport + tags: + - Backend - Distributors Import/Export + summary: /distributors/import/validate - Validate CSV for distributor import + description: | + Upload and validate a CSV file for bulk distributor import. Returns a row-by-row report + with a verdict per row (`valid` / `error` / `warning`), separating blocking `errors[]` + from non-blocking `warnings[]`. Validated data is stored in a temporary session + (30 min TTL) keyed by `import_id`, which must be passed to `/distributors/import/confirm` + to actually create or update the distributors. + + **CSV format** + + - Columns (in any order): `company_name`, `description`, `vat_number`, `address`, `city`, + `main_contact`, `email`, `phone`, `language`, `notes`. + - Required: `company_name`, `vat_number`. All other columns are optional. + - `phone`, when present, must include the leading `+CC` country prefix + (e.g. `+39 02 1234567`). Bare local numbers are rejected with `invalid_format`. + - `language` accepts `it` or `en` (defaults to `it` when empty). + - The first data row is row `2`. + - Standard CSV (RFC 4180) — fields containing commas, quotes or newlines must be + double-quoted (`"note, with comma"`). Spreadsheet tools auto-quote on save. + + **Row outcomes** + + | status | What it means | Confirm action | + |--------|---------------|----------------| + | `valid` | All checks passed | CREATE | + | `error` | At least one blocking error in `errors[]` (`required`, `invalid_format`, `too_long`, `duplicate_in_csv`, `archived`, …) | always skipped | + | `warning` | A distributor with the same `vat` already exists in the database (in `warnings[]`) | UPDATE if `override: true`, else skipped | + + Distributor imports cannot produce `ambiguous` rows — that status only applies to user imports. + + **Limits** — max 10 MB, max 1000 rows. Larger files return `400`. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + responses: + '200': + description: Validation report + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "distributors import validated" + data: + $ref: '#/components/schemas/ImportValidationResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /distributors/import/confirm: + post: + operationId: confirmDistributorsImport + tags: + - Backend - Distributors Import/Export + summary: /distributors/import/confirm - Execute validated distributor import + description: | + Confirm and execute a previously validated distributor import. Reuses `POST /distributors` + (CREATE) and `PUT /distributors/{id}` (UPDATE) for each row. + + **Inputs** + + - `import_id` — from the `/validate` response. Must still be valid (sessions expire after 30 min). + - `override` — global flag. When `true`, every row with status `warning` (a + distributor with the same VAT already exists in the DB) is UPDATEd with the CSV + values; name, description, address, city, main_contact, email, phone, language, + notes all get overwritten. When omitted/false, warning rows are skipped. + - `resolutions` — not applicable to distributors. + + **Behaviour per row status** + + | status | `override` false | `override` true | + |--------|------------------|------------------| + | `valid` | CREATE | CREATE | + | `error` | skipped (`reason: error`) | skipped (`reason: error`) | + | `warning` | skipped (`reason: warning_not_overridden`) | UPDATE — all custom_data fields overwritten from CSV (name is the lookup key, never changed) | + + **Atomicity** — non-atomic: failures during creation/update are reported per-row; + rows that succeeded before the failure are kept. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImportConfirmRequest' + responses: + '200': + description: Import results + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "distributors imported successfully" + data: + $ref: '#/components/schemas/ImportConfirmResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # =========================================================================== + # RESELLERS IMPORT + # =========================================================================== + /resellers/import/template: + get: + operationId: getResellersImportTemplate + tags: + - Backend - Resellers Import/Export + summary: /resellers/import/template - Download CSV import template + description: | + Download a UTF-8 CSV file pre-populated with the expected header row and a few example + data rows for the reseller import flow. Columns: `company_name`, `description`, `vat_number`, + `address`, `city`, `main_contact`, `email`, `phone`, `language`, `notes`. Sent as a + `text/csv` attachment with `Content-Disposition: attachment; filename="resellers_import_template.csv"`. + responses: + '200': + description: CSV template downloaded + content: + text/csv: + schema: + type: string + format: binary '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - # =========================================================================== - # BUSINESS HIERARCHY - DISTRIBUTORS - # =========================================================================== - /distributors: - get: - operationId: getDistributors + /resellers/import/validate: + post: + operationId: validateResellersImport tags: - - Backend - Distributors - summary: /distributors - List distributors - description: Get paginated list of distributors (Owner only) - parameters: - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/DistributorSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - - $ref: '#/components/parameters/OrganizationStatusFilterParam' - - $ref: '#/components/parameters/OrgCreatedByFilterParam' - - $ref: '#/components/parameters/OwnedByOrganizationFilterParam' - - $ref: '#/components/parameters/IncludeHierarchyParam' + - Backend - Resellers Import/Export + summary: /resellers/import/validate - Validate CSV for reseller import + description: | + Upload and validate a CSV file for bulk reseller import. Returns a row-by-row report + with a verdict per row (`valid` / `error` / `warning`), separating blocking `errors[]` + from non-blocking `warnings[]`. Validated data is stored in a temporary session + (30 min TTL) keyed by `import_id`, which must be passed to `/resellers/import/confirm` + to actually create or update the resellers. + + **CSV format** + + - Columns (in any order): `company_name`, `description`, `vat_number`, `address`, `city`, + `main_contact`, `email`, `phone`, `language`, `notes`. + - Required: `company_name`, `vat_number`. All other columns are optional. + - `phone`, when present, must include the leading `+CC` country prefix + (e.g. `+39 02 1234567`). Bare local numbers are rejected with `invalid_format`. + - `language` accepts `it` or `en` (defaults to `it` when empty). + - The first data row is row `2`. + - Standard CSV (RFC 4180) — fields containing commas, quotes or newlines must be + double-quoted (`"note, with comma"`). Spreadsheet tools auto-quote on save. + + **Row outcomes** + + | status | What it means | Confirm action | + |--------|---------------|----------------| + | `valid` | All checks passed | CREATE | + | `error` | At least one blocking error in `errors[]` (`required`, `invalid_format`, `too_long`, `duplicate_in_csv`, `archived`, …) | always skipped | + | `warning` | A reseller with the same `vat` already exists in the database (in `warnings[]`) | UPDATE if `override: true`, else skipped | + + Reseller imports cannot produce `ambiguous` rows — that status only applies to user imports. + + **Limits** — max 10 MB, max 1000 rows. Larger files return `400`. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary responses: '200': - description: Distributors retrieved successfully + description: Validation report content: application/json: schema: @@ -6457,36 +7860,53 @@ paths: example: 200 message: type: string - example: "distributors retrieved successfully" + example: "resellers import validated" data: - type: object - properties: - distributors: - type: array - items: - $ref: '#/components/schemas/OrganizationWithStats' - pagination: - $ref: '#/components/schemas/Pagination' + $ref: '#/components/schemas/ImportValidationResult' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + /resellers/import/confirm: post: - operationId: createDistributor + operationId: confirmResellersImport tags: - - Backend - Distributors - summary: /distributors - Create distributor - description: Create a new distributor organization (Owner only) + - Backend - Resellers Import/Export + summary: /resellers/import/confirm - Execute validated reseller import + description: | + Confirm and execute a previously validated reseller import. Reuses `POST /resellers` + (CREATE) and `PUT /resellers/{id}` (UPDATE) for each row. + + **Inputs** + + - `import_id` — from the `/validate` response. Must still be valid (sessions expire after 30 min). + - `override` — global flag. When `true`, every row with status `warning` (a reseller + with the same VAT already exists in the DB) is UPDATEd with the CSV values. When + omitted/false, warning rows are skipped. + - `resolutions` — not applicable to resellers. + + **Behaviour per row status** + + | status | `override` false | `override` true | + |--------|------------------|------------------| + | `valid` | CREATE | CREATE | + | `error` | skipped (`reason: error`) | skipped (`reason: error`) | + | `warning` | skipped (`reason: warning_not_overridden`) | UPDATE — all custom_data fields overwritten from CSV | + + **Atomicity** — non-atomic: failures during creation/update are reported per-row; + rows that succeeded before the failure are kept. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/OrganizationInput' + $ref: '#/components/schemas/ImportConfirmRequest' responses: - '201': - description: Distributor created successfully + '200': + description: Import results content: application/json: schema: @@ -6494,82 +7914,99 @@ paths: properties: code: type: integer - example: 201 + example: 200 message: type: string - example: "distributor created successfully" + example: "resellers imported successfully" data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/ImportConfirmResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '422': - $ref: '#/components/responses/UnprocessableEntity' - /distributors/{id}: + # =========================================================================== + # CUSTOMERS IMPORT + # =========================================================================== + /customers/import/template: get: - operationId: getDistributorById + operationId: getCustomersImportTemplate tags: - - Backend - Distributors - summary: /distributors/{id} - Get single distributor - description: Get a specific distributor by ID (Owner only) - parameters: - - name: id - in: path - required: true - description: Distributor Logto ID - schema: - type: string - example: "jf584cz36kce" + - Backend - Customers Import/Export + summary: /customers/import/template - Download CSV import template + description: | + Download a UTF-8 CSV file pre-populated with the expected header row and a few example + data rows for the customer import flow. Columns: `company_name`, `description`, `vat_number`, + `address`, `city`, `main_contact`, `email`, `phone`, `language`, `notes`. Sent as a + `text/csv` attachment with `Content-Disposition: attachment; filename="customers_import_template.csv"`. responses: '200': - description: Distributor retrieved successfully + description: CSV template downloaded content: - application/json: + text/csv: schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "distributor retrieved successfully" - data: - $ref: '#/components/schemas/Organization' + type: string + format: binary '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - put: - operationId: updateDistributor + /customers/import/validate: + post: + operationId: validateCustomersImport tags: - - Backend - Distributors - summary: /distributors/{id} - Update distributor - description: Update a distributor organization (Owner only) - parameters: - - name: id - in: path - required: true - description: Distributor Logto ID - schema: - type: string - example: "jf584cz36kce" + - Backend - Customers Import/Export + summary: /customers/import/validate - Validate CSV for customer import + description: | + Upload and validate a CSV file for bulk customer import. Returns a row-by-row report + with a verdict per row (`valid` / `error` / `warning`), separating blocking `errors[]` + from non-blocking `warnings[]`. Validated data is stored in a temporary session + (30 min TTL) keyed by `import_id`, which must be passed to `/customers/import/confirm` + to actually create or update the customers. + + **CSV format** + + - Columns (in any order): `company_name`, `description`, `vat_number`, `address`, `city`, + `main_contact`, `email`, `phone`, `language`, `notes`. + - Required: `company_name`, `vat_number`. All other columns are optional. + - `phone`, when present, must include the leading `+CC` country prefix + (e.g. `+39 02 1234567`). Bare local numbers are rejected with `invalid_format`. + - `language` accepts `it` or `en` (defaults to `it` when empty). + - The first data row is row `2`. + - Standard CSV (RFC 4180) — fields containing commas, quotes or newlines must be + double-quoted (`"note, with comma"`). Spreadsheet tools auto-quote on save. + + **Row outcomes** + + | status | What it means | Confirm action | + |--------|---------------|----------------| + | `valid` | All checks passed | CREATE | + | `error` | At least one blocking error in `errors[]` (`required`, `invalid_format`, `too_long`, `duplicate_in_csv`, …) | always skipped | + + Customer imports never produce `warning`, `archived` or `ambiguous` rows. The DB + does not enforce uniqueness on customer name or VAT, so every well-formed customer + row is `valid` and creates a new record at confirm. The `override` flag is + therefore a no-op for `/customers/import/confirm`. + + **Limits** — max 10 MB, max 1000 rows. Larger files return `400`. requestBody: required: true content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/OrganizationInput' + type: object + required: + - file + properties: + file: + type: string + format: binary responses: '200': - description: Distributor updated successfully + description: Validation report content: application/json: schema: @@ -6580,37 +8017,51 @@ paths: example: 200 message: type: string - example: "distributor updated successfully" + example: "customers import validated" data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/ImportValidationResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '422': - $ref: '#/components/responses/UnprocessableEntity' - delete: - operationId: deleteDistributor - tags: - - Backend - Distributors - summary: /distributors/{id} - Delete distributor - description: Delete a distributor organization (Owner only) - parameters: - - name: id - in: path - required: true - description: Distributor Logto ID - schema: - type: string - example: "jf584cz36kce" + /customers/import/confirm: + post: + operationId: confirmCustomersImport + tags: + - Backend - Customers Import/Export + summary: /customers/import/confirm - Execute validated customer import + description: | + Confirm and execute a previously validated customer import. Calls `POST /customers` + (CREATE) for every `valid` row. + + **Inputs** + + - `import_id` — from the `/validate` response. Must still be valid (sessions expire after 30 min). + - `override` — **no-op for customers**: validate never emits `warning` rows since the + DB enforces no uniqueness on customer name or VAT, so there is nothing to UPDATE. + - `resolutions` — not applicable to customers. + + **Behaviour per row status** + + | status | confirm action | + |--------|----------------| + | `valid` | CREATE | + | `error` | skipped (`reason: error`) | + + **Atomicity** — non-atomic: failures during creation are reported per-row; + rows that succeeded before the failure are kept. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImportConfirmRequest' responses: '200': - description: Distributor deleted successfully + description: Import results content: application/json: schema: @@ -6621,44 +8072,31 @@ paths: example: 200 message: type: string - example: "distributor deleted successfully" + example: "customers imported successfully" data: - type: object - properties: - deleted_systems_count: - type: integer - description: Number of systems cascade soft-deleted across the distributor hierarchy - example: 5 - deleted_users_count: - type: integer - description: Number of users cascade soft-deleted across the distributor hierarchy - example: 12 + $ref: '#/components/schemas/ImportConfirmResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /distributors/{id}/stats: + # =========================================================================== + # HEALTH CHECK + # =========================================================================== + /health: get: - operationId: getDistributorStats + operationId: getHealth tags: - - Backend - Distributors - summary: /distributors/{id}/stats - Get distributor statistics - description: Get users, systems, resellers and customers count for a specific distributor (Owner only) - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Distributor Logto ID + - Backend - Health + - Collect - Health + summary: /health - Health check + description: Check if the service is healthy (available on both backend and collect servers) + security: [] responses: '200': - description: Distributor stats retrieved successfully + description: Service is healthy content: application/json: schema: @@ -6669,35 +8107,13 @@ paths: example: 200 message: type: string - example: "distributor stats retrieved successfully" + example: "service healthy" data: - $ref: '#/components/schemas/DistributorStats' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + type: object + description: Health data (collect includes worker metrics) + nullable: true '404': - $ref: '#/components/responses/NotFound' - - /distributors/{id}/suspend: - patch: - operationId: suspendDistributor - tags: - - Backend - Distributors - summary: /distributors/{id}/suspend - Suspend distributor - description: Suspend a distributor and all its users (cascade suspension). Owner only. - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Distributor Logto ID - responses: - '200': - description: Distributor suspended successfully + description: Endpoint not found content: application/json: schema: @@ -6705,57 +8121,47 @@ paths: properties: code: type: integer - example: 200 + example: 404 message: type: string - example: "distributor suspended successfully" + example: "endpoint not found" data: type: object - properties: - distributor: - $ref: '#/components/schemas/Organization' - suspended_resellers_count: - type: integer - example: 3 - description: Number of resellers that were cascade-suspended - suspended_customers_count: - type: integer - example: 10 - description: Number of customers that were cascade-suspended - suspended_users_count: - type: integer - example: 25 - description: Number of users that were cascade-suspended - suspended_systems_count: - type: integer - example: 15 - description: Number of systems that were cascade-suspended - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' + '503': + description: Service is unhealthy (collect service only) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' - /distributors/{id}/reactivate: - patch: - operationId: reactivateDistributor + # =========================================================================== + # FILTERS + # =========================================================================== + /filters/systems: + get: + operationId: getSystemFilters tags: - - Backend - Distributors - summary: /distributors/{id}/reactivate - Reactivate distributor - description: Reactivate a suspended distributor and all its cascade-suspended users. Owner only. - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Distributor Logto ID + - Backend - Filters + summary: /filters/systems - Get aggregated system filters + description: | + Aggregated endpoint that returns products, creators and versions + for the systems views. Auth is checked once, then the three datasets + are fetched in parallel. Respects RBAC hierarchy - users only see + data from systems they can access. + + **Version Format**: Returns versions in prefixed format `product:version` + (e.g., `"nsec:1.2.3"`, `"ns8:1.2.3"`) to avoid ambiguity when the same + version number exists for multiple products. + + Organizations dropdown is populated by the dedicated `/api/organizations` + endpoint, which supports search and pagination. responses: '200': - description: Distributor reactivated successfully + description: System filters retrieved successfully content: application/json: schema: @@ -6766,55 +8172,91 @@ paths: example: 200 message: type: string - example: "distributor reactivated successfully" + example: "system filters retrieved successfully" data: type: object properties: - distributor: - $ref: '#/components/schemas/Organization' - reactivated_resellers_count: - type: integer - example: 3 - description: Number of resellers that were cascade-reactivated - reactivated_customers_count: - type: integer - example: 10 - description: Number of customers that were cascade-reactivated - reactivated_users_count: - type: integer - example: 25 - description: Number of users that were cascade-reactivated - reactivated_systems_count: - type: integer - example: 15 - description: Number of systems that were cascade-reactivated - '400': - $ref: '#/components/responses/BadRequest' + products: + type: array + items: + type: string + description: List of unique product names (system types) + created_by: + type: array + items: + type: object + properties: + user_id: + type: string + description: User logto_id from created_by JSONB field (not UUID) + example: "pnqjmgmk937x" + name: + type: string + description: User full name from created_by JSONB field + example: "Company Owner" + email: + type: string + description: User email from created_by JSONB field + example: "owner@example.com" + organization_id: + type: string + description: Creator organization logto_id from created_by JSONB field + example: "2wl3iixbc8ua" + organization_name: + type: string + description: Creator organization name from created_by JSONB field + example: "Nethesis Italia" + description: List of users who created systems (one entry per user; organization attached to disambiguate homonyms) + versions: + type: array + items: + type: object + properties: + product: + type: string + description: Product type name + example: "NethSecurity" + versions: + type: array + items: + type: string + description: List of versions in prefixed format (product:version) + example: ["nsec:8.6.0", "nsec:8.5.2"] + description: Versions grouped by product type + example: + code: 200 + message: "system filters retrieved successfully" + data: + products: ["NethSecurity", "NethServer"] + created_by: + - user_id: "pnqjmgmk937x" + name: "Company Owner" + email: "owner@example.com" + organization_id: "2wl3iixbc8ua" + organization_name: "Nethesis Italia" + versions: + - product: "nsec" + versions: ["nsec:8.6.0", "nsec:8.5.2"] + - product: "ns8" + versions: ["ns8:1.2.3", "ns8:1.1.0"] '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /distributors/{id}/restore: - patch: - operationId: restoreDistributor + # =========================================================================== + # ROLES & ORGANIZATIONS + # =========================================================================== + /organization-roles: + get: + operationId: getOrganizationRoles tags: - - Backend - Distributors - summary: /distributors/{id}/restore - Restore soft-deleted distributor - description: Restore a soft-deleted distributor (requires manage:distributors permission and hierarchical validation) - parameters: - - name: id - in: path - required: true - description: Distributor Logto ID - schema: - type: string - example: "jf584cz36kce" + - Backend - Metadata + summary: /organization-roles - Get all organization roles + description: Get all available organization roles with their IDs and descriptions responses: '200': - description: Distributor restored successfully + description: Organization roles retrieved successfully content: application/json: schema: @@ -6825,59 +8267,62 @@ paths: example: 200 message: type: string - example: "distributor restored successfully" + example: "organization roles retrieved successfully" data: type: object properties: - restored_systems_count: - type: integer - description: Number of systems cascade restored across the distributor hierarchy - example: 5 - restored_users_count: - type: integer - description: Number of users cascade restored across the distributor hierarchy - example: 12 - '400': - description: Bad request - Distributor is not deleted - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 400 - message: - type: string - example: "distributor is not deleted" - data: - type: object - nullable: true + organizationRoles: + type: array + items: + $ref: '#/components/schemas/OrganizationRole' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /distributors/{id}/destroy: - delete: - operationId: destroyDistributor + /organizations: + get: + operationId: getOrganizations tags: - - Backend - Distributors - summary: /distributors/{id}/destroy - Permanently delete distributor - description: Permanently delete a distributor organization (requires destroy:distributors permission and hierarchical validation) + - Backend - Metadata + summary: /organizations - Get available organizations + description: | + Paginated list of organizations across the caller's hierarchy + (distributors + resellers + customers). Supports text search and + per-field filters; intended for filter dropdowns and assignment + pickers. parameters: - - name: id - in: path - required: true - description: Distributor Logto ID + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/OrganizationSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + - name: name + in: query + required: false + description: Filter by organization name (exact match) + schema: + type: string + - name: description + in: query + required: false + description: Filter by organization description (exact match) + schema: + type: string + - name: type + in: query + required: false + description: Filter by organization type + schema: + type: string + enum: [distributor, reseller, customer] + - name: created_by + in: query + required: false + description: Filter by creator user ID schema: type: string - example: "jf584cz36kce" responses: '200': - description: Distributor permanently destroyed + description: Organizations retrieved successfully content: application/json: schema: @@ -6888,27 +8333,42 @@ paths: example: 200 message: type: string - example: "distributor permanently destroyed" + example: "organizations retrieved successfully" data: type: object - nullable: true + properties: + organizations: + type: array + items: + $ref: '#/components/schemas/OrganizationReference' + pagination: + $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /distributors/totals: + # =========================================================================== + # BUSINESS HIERARCHY - RESELLERS + # =========================================================================== + /resellers: get: - operationId: getDistributorsTotals + operationId: getResellers tags: - - Backend - Distributors Stats - summary: /distributors/totals - Get distributors totals - description: Get total count of distributors accessible to the user (Owner only) + - Backend - Resellers + summary: /resellers - List resellers + description: Get paginated list of resellers (Owner + Distributor) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/ResellerSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + - $ref: '#/components/parameters/OrganizationStatusFilterParam' + - $ref: '#/components/parameters/OrgCreatedByFilterParam' + - $ref: '#/components/parameters/OwnedByOrganizationFilterParam' + - $ref: '#/components/parameters/IncludeHierarchyParam' responses: '200': - description: Distributors totals retrieved successfully + description: Resellers retrieved successfully content: application/json: schema: @@ -6919,26 +8379,36 @@ paths: example: 200 message: type: string - example: "distributors totals retrieved" + example: "resellers retrieved successfully" data: - $ref: '#/components/schemas/Totals' + type: object + properties: + resellers: + type: array + items: + $ref: '#/components/schemas/OrganizationWithStats' + pagination: + $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /distributors/trend: - get: - operationId: getDistributorsTrend + post: + operationId: createReseller tags: - - Backend - Distributors Stats - summary: /distributors/trend - Get distributors trend data - description: Get trend analysis of distributors over time with cumulative counts (Owner only) - parameters: - - $ref: '#/components/parameters/TrendPeriodParam' + - Backend - Resellers + summary: /resellers - Create reseller + description: Create a new reseller organization (Owner + Distributor) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationInput' responses: - '200': - description: Distributors trend data retrieved successfully + '201': + description: Reseller created successfully content: application/json: schema: @@ -6946,135 +8416,82 @@ paths: properties: code: type: integer - example: 200 + example: 201 message: type: string - example: "distributors trend retrieved successfully" + example: "reseller created successfully" data: - $ref: '#/components/schemas/TrendResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /distributors/export: - get: - operationId: exportDistributors - tags: - - Backend - Distributors Import/Export - summary: /distributors/export - Export distributors to CSV or PDF - description: Export distributors to CSV or PDF format with applied filters (max 10,000 distributors) - parameters: - - name: format - in: query - required: true - description: Export format (csv or pdf) - schema: - type: string - enum: [csv, pdf] - example: "csv" - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/DistributorSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - responses: - '200': - description: Distributors exported successfully - content: - text/csv: - schema: - type: string - format: binary - application/pdf: - schema: - type: string - format: binary + $ref: '#/components/schemas/Organization' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/UnprocessableEntity' - # =========================================================================== - # DISTRIBUTORS IMPORT - # =========================================================================== - /distributors/import/template: - get: - operationId: getDistributorsImportTemplate - tags: - - Backend - Distributors Import/Export - summary: /distributors/import/template - Download CSV import template - description: | - Download a UTF-8 CSV file pre-populated with the expected header row and a few example - data rows for the distributor import flow. Columns: `company_name`, `description`, `vat_number`, - `address`, `city`, `main_contact`, `email`, `phone`, `language`, `notes`. Sent as a - `text/csv` attachment with `Content-Disposition: attachment; filename="distributors_import_template.csv"`. + /resellers/{id}: + get: + operationId: getResellerById + tags: + - Backend - Resellers + summary: /resellers/{id} - Get single reseller + description: Get a specific reseller by ID (Owner + Distributor) + parameters: + - name: id + in: path + required: true + description: Reseller Logto ID + schema: + type: string + example: "jf584cz36kce" responses: '200': - description: CSV template downloaded + description: Reseller retrieved successfully content: - text/csv: + application/json: schema: - type: string - format: binary + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "reseller retrieved successfully" + data: + $ref: '#/components/schemas/Organization' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /distributors/import/validate: - post: - operationId: validateDistributorsImport + put: + operationId: updateReseller tags: - - Backend - Distributors Import/Export - summary: /distributors/import/validate - Validate CSV for distributor import - description: | - Upload and validate a CSV file for bulk distributor import. Returns a row-by-row report - with a verdict per row (`valid` / `error` / `warning`), separating blocking `errors[]` - from non-blocking `warnings[]`. Validated data is stored in a temporary session - (30 min TTL) keyed by `import_id`, which must be passed to `/distributors/import/confirm` - to actually create or update the distributors. - - **CSV format** - - - Columns (in any order): `company_name`, `description`, `vat_number`, `address`, `city`, - `main_contact`, `email`, `phone`, `language`, `notes`. - - Required: `company_name`, `vat_number`. All other columns are optional. - - `phone`, when present, must include the leading `+CC` country prefix - (e.g. `+39 02 1234567`). Bare local numbers are rejected with `invalid_format`. - - `language` accepts `it` or `en` (defaults to `it` when empty). - - The first data row is row `2`. - - Standard CSV (RFC 4180) — fields containing commas, quotes or newlines must be - double-quoted (`"note, with comma"`). Spreadsheet tools auto-quote on save. - - **Row outcomes** - - | status | What it means | Confirm action | - |--------|---------------|----------------| - | `valid` | All checks passed | CREATE | - | `error` | At least one blocking error in `errors[]` (`required`, `invalid_format`, `too_long`, `duplicate_in_csv`, `archived`, …) | always skipped | - | `warning` | A distributor with the same `vat` already exists in the database (in `warnings[]`) | UPDATE if `override: true`, else skipped | - - Distributor imports cannot produce `ambiguous` rows — that status only applies to user imports. - - **Limits** — max 10 MB, max 1000 rows. Larger files return `400`. + - Backend - Resellers + summary: /resellers/{id} - Update reseller + description: Update a reseller organization (Owner + Distributor) + parameters: + - name: id + in: path + required: true + description: Reseller Logto ID + schema: + type: string + example: "jf584cz36kce" requestBody: required: true content: - multipart/form-data: + application/json: schema: - type: object - required: - - file - properties: - file: - type: string - format: binary + $ref: '#/components/schemas/OrganizationInput' responses: '200': - description: Validation report + description: Reseller updated successfully content: application/json: schema: @@ -7085,54 +8502,37 @@ paths: example: 200 message: type: string - example: "distributors import validated" + example: "reseller updated successfully" data: - $ref: '#/components/schemas/ImportValidationResult' + $ref: '#/components/schemas/Organization' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' - /distributors/import/confirm: - post: - operationId: confirmDistributorsImport + delete: + operationId: deleteReseller tags: - - Backend - Distributors Import/Export - summary: /distributors/import/confirm - Execute validated distributor import - description: | - Confirm and execute a previously validated distributor import. Reuses `POST /distributors` - (CREATE) and `PUT /distributors/{id}` (UPDATE) for each row. - - **Inputs** - - - `import_id` — from the `/validate` response. Must still be valid (sessions expire after 30 min). - - `override` — global flag. When `true`, every row with status `warning` (a - distributor with the same VAT already exists in the DB) is UPDATEd with the CSV - values; name, description, address, city, main_contact, email, phone, language, - notes all get overwritten. When omitted/false, warning rows are skipped. - - `resolutions` — not applicable to distributors. - - **Behaviour per row status** - - | status | `override` false | `override` true | - |--------|------------------|------------------| - | `valid` | CREATE | CREATE | - | `error` | skipped (`reason: error`) | skipped (`reason: error`) | - | `warning` | skipped (`reason: warning_not_overridden`) | UPDATE — all custom_data fields overwritten from CSV (name is the lookup key, never changed) | - - **Atomicity** — non-atomic: failures during creation/update are reported per-row; - rows that succeeded before the failure are kept. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ImportConfirmRequest' + - Backend - Resellers + summary: /resellers/{id} - Delete reseller + description: Delete a reseller organization (Owner + Distributor) + parameters: + - name: id + in: path + required: true + description: Reseller Logto ID + schema: + type: string + example: "jf584cz36kce" responses: '200': - description: Import results + description: Reseller deleted successfully content: application/json: schema: @@ -7143,94 +8543,83 @@ paths: example: 200 message: type: string - example: "distributors imported successfully" + example: "reseller deleted successfully" data: - $ref: '#/components/schemas/ImportConfirmResult' + type: object + properties: + deleted_systems_count: + type: integer + description: Number of systems cascade soft-deleted across the reseller hierarchy + example: 3 + deleted_users_count: + type: integer + description: Number of users cascade soft-deleted across the reseller hierarchy + example: 8 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - # =========================================================================== - # RESELLERS IMPORT - # =========================================================================== - /resellers/import/template: + /resellers/{id}/stats: get: - operationId: getResellersImportTemplate + operationId: getResellerStats tags: - - Backend - Resellers Import/Export - summary: /resellers/import/template - Download CSV import template - description: | - Download a UTF-8 CSV file pre-populated with the expected header row and a few example - data rows for the reseller import flow. Columns: `company_name`, `description`, `vat_number`, - `address`, `city`, `main_contact`, `email`, `phone`, `language`, `notes`. Sent as a - `text/csv` attachment with `Content-Disposition: attachment; filename="resellers_import_template.csv"`. + - Backend - Resellers + summary: /resellers/{id}/stats - Get reseller statistics + description: Get users, systems and customers count for a specific reseller (Owner + Distributor + own Reseller) + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reseller Logto ID responses: '200': - description: CSV template downloaded + description: Reseller stats retrieved successfully content: - text/csv: + application/json: schema: - type: string - format: binary + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "reseller stats retrieved successfully" + data: + $ref: '#/components/schemas/ResellerStats' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - - /resellers/import/validate: - post: - operationId: validateResellersImport - tags: - - Backend - Resellers Import/Export - summary: /resellers/import/validate - Validate CSV for reseller import - description: | - Upload and validate a CSV file for bulk reseller import. Returns a row-by-row report - with a verdict per row (`valid` / `error` / `warning`), separating blocking `errors[]` - from non-blocking `warnings[]`. Validated data is stored in a temporary session - (30 min TTL) keyed by `import_id`, which must be passed to `/resellers/import/confirm` - to actually create or update the resellers. - - **CSV format** - - - Columns (in any order): `company_name`, `description`, `vat_number`, `address`, `city`, - `main_contact`, `email`, `phone`, `language`, `notes`. - - Required: `company_name`, `vat_number`. All other columns are optional. - - `phone`, when present, must include the leading `+CC` country prefix - (e.g. `+39 02 1234567`). Bare local numbers are rejected with `invalid_format`. - - `language` accepts `it` or `en` (defaults to `it` when empty). - - The first data row is row `2`. - - Standard CSV (RFC 4180) — fields containing commas, quotes or newlines must be - double-quoted (`"note, with comma"`). Spreadsheet tools auto-quote on save. - - **Row outcomes** - - | status | What it means | Confirm action | - |--------|---------------|----------------| - | `valid` | All checks passed | CREATE | - | `error` | At least one blocking error in `errors[]` (`required`, `invalid_format`, `too_long`, `duplicate_in_csv`, `archived`, …) | always skipped | - | `warning` | A reseller with the same `vat` already exists in the database (in `warnings[]`) | UPDATE if `override: true`, else skipped | - - Reseller imports cannot produce `ambiguous` rows — that status only applies to user imports. - - **Limits** — max 10 MB, max 1000 rows. Larger files return `400`. - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - file - properties: - file: - type: string - format: binary + '404': + $ref: '#/components/responses/NotFound' + + /resellers/{id}/suspend: + patch: + operationId: suspendReseller + tags: + - Backend - Resellers + summary: /resellers/{id}/suspend - Suspend reseller + description: Suspend a reseller and all its users (cascade suspension). Owner and Distributor (hierarchical) can suspend. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reseller Logto ID responses: '200': - description: Validation report + description: Reseller suspended successfully content: application/json: schema: @@ -7241,53 +8630,50 @@ paths: example: 200 message: type: string - example: "resellers import validated" + example: "reseller suspended successfully" data: - $ref: '#/components/schemas/ImportValidationResult' + type: object + properties: + reseller: + $ref: '#/components/schemas/Organization' + suspended_customers_count: + type: integer + example: 5 + description: Number of customers that were cascade-suspended + suspended_users_count: + type: integer + example: 15 + description: Number of users that were cascade-suspended + suspended_systems_count: + type: integer + example: 10 + description: Number of systems that were cascade-suspended '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /resellers/import/confirm: - post: - operationId: confirmResellersImport + /resellers/{id}/reactivate: + patch: + operationId: reactivateReseller tags: - - Backend - Resellers Import/Export - summary: /resellers/import/confirm - Execute validated reseller import - description: | - Confirm and execute a previously validated reseller import. Reuses `POST /resellers` - (CREATE) and `PUT /resellers/{id}` (UPDATE) for each row. - - **Inputs** - - - `import_id` — from the `/validate` response. Must still be valid (sessions expire after 30 min). - - `override` — global flag. When `true`, every row with status `warning` (a reseller - with the same VAT already exists in the DB) is UPDATEd with the CSV values. When - omitted/false, warning rows are skipped. - - `resolutions` — not applicable to resellers. - - **Behaviour per row status** - - | status | `override` false | `override` true | - |--------|------------------|------------------| - | `valid` | CREATE | CREATE | - | `error` | skipped (`reason: error`) | skipped (`reason: error`) | - | `warning` | skipped (`reason: warning_not_overridden`) | UPDATE — all custom_data fields overwritten from CSV | - - **Atomicity** — non-atomic: failures during creation/update are reported per-row; - rows that succeeded before the failure are kept. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ImportConfirmRequest' + - Backend - Resellers + summary: /resellers/{id}/reactivate - Reactivate reseller + description: Reactivate a suspended reseller and all its cascade-suspended users. Owner and Distributor (hierarchical) can reactivate. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reseller Logto ID responses: '200': - description: Import results + description: Reseller reactivated successfully content: application/json: schema: @@ -7298,96 +8684,145 @@ paths: example: 200 message: type: string - example: "resellers imported successfully" + example: "reseller reactivated successfully" data: - $ref: '#/components/schemas/ImportConfirmResult' + type: object + properties: + reseller: + $ref: '#/components/schemas/Organization' + reactivated_customers_count: + type: integer + example: 5 + description: Number of customers that were cascade-reactivated + reactivated_users_count: + type: integer + example: 15 + description: Number of users that were cascade-reactivated + reactivated_systems_count: + type: integer + example: 10 + description: Number of systems that were cascade-reactivated '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - # =========================================================================== - # CUSTOMERS IMPORT - # =========================================================================== - /customers/import/template: - get: - operationId: getCustomersImportTemplate + /resellers/{id}/restore: + patch: + operationId: restoreReseller tags: - - Backend - Customers Import/Export - summary: /customers/import/template - Download CSV import template - description: | - Download a UTF-8 CSV file pre-populated with the expected header row and a few example - data rows for the customer import flow. Columns: `company_name`, `description`, `vat_number`, - `address`, `city`, `main_contact`, `email`, `phone`, `language`, `notes`. Sent as a - `text/csv` attachment with `Content-Disposition: attachment; filename="customers_import_template.csv"`. + - Backend - Resellers + summary: /resellers/{id}/restore - Restore soft-deleted reseller + description: Restore a soft-deleted reseller (requires manage:resellers permission and hierarchical validation) + parameters: + - name: id + in: path + required: true + description: Reseller Logto ID + schema: + type: string + example: "jf584cz36kce" responses: '200': - description: CSV template downloaded + description: Reseller restored successfully content: - text/csv: + application/json: schema: - type: string - format: binary + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "reseller restored successfully" + data: + type: object + properties: + restored_systems_count: + type: integer + description: Number of systems cascade restored across the reseller hierarchy + example: 3 + restored_users_count: + type: integer + description: Number of users cascade restored across the reseller hierarchy + example: 8 + '400': + description: Bad request - Reseller is not deleted + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 400 + message: + type: string + example: "reseller is not deleted" + data: + type: object + nullable: true '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /customers/import/validate: - post: - operationId: validateCustomersImport + /resellers/{id}/destroy: + delete: + operationId: destroyReseller tags: - - Backend - Customers Import/Export - summary: /customers/import/validate - Validate CSV for customer import - description: | - Upload and validate a CSV file for bulk customer import. Returns a row-by-row report - with a verdict per row (`valid` / `error` / `warning`), separating blocking `errors[]` - from non-blocking `warnings[]`. Validated data is stored in a temporary session - (30 min TTL) keyed by `import_id`, which must be passed to `/customers/import/confirm` - to actually create or update the customers. - - **CSV format** - - - Columns (in any order): `company_name`, `description`, `vat_number`, `address`, `city`, - `main_contact`, `email`, `phone`, `language`, `notes`. - - Required: `company_name`, `vat_number`. All other columns are optional. - - `phone`, when present, must include the leading `+CC` country prefix - (e.g. `+39 02 1234567`). Bare local numbers are rejected with `invalid_format`. - - `language` accepts `it` or `en` (defaults to `it` when empty). - - The first data row is row `2`. - - Standard CSV (RFC 4180) — fields containing commas, quotes or newlines must be - double-quoted (`"note, with comma"`). Spreadsheet tools auto-quote on save. - - **Row outcomes** - - | status | What it means | Confirm action | - |--------|---------------|----------------| - | `valid` | All checks passed | CREATE | - | `error` | At least one blocking error in `errors[]` (`required`, `invalid_format`, `too_long`, `duplicate_in_csv`, …) | always skipped | - - Customer imports never produce `warning`, `archived` or `ambiguous` rows. The DB - does not enforce uniqueness on customer name or VAT, so every well-formed customer - row is `valid` and creates a new record at confirm. The `override` flag is - therefore a no-op for `/customers/import/confirm`. - - **Limits** — max 10 MB, max 1000 rows. Larger files return `400`. - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - file - properties: - file: - type: string - format: binary + - Backend - Resellers + summary: /resellers/{id}/destroy - Permanently delete reseller + description: Permanently delete a reseller organization (requires destroy:resellers permission and hierarchical validation) + parameters: + - name: id + in: path + required: true + description: Reseller Logto ID + schema: + type: string + example: "jf584cz36kce" + responses: + '200': + description: Reseller permanently destroyed + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "reseller permanently destroyed" + data: + type: object + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /resellers/totals: + get: + operationId: getResellersTotals + tags: + - Backend - Resellers Stats + summary: /resellers/totals - Get resellers totals + description: Get total count of resellers accessible to the user (Owner + Distributor) responses: '200': - description: Validation report + description: Resellers totals retrieved successfully content: application/json: schema: @@ -7398,51 +8833,26 @@ paths: example: 200 message: type: string - example: "customers import validated" + example: "resellers totals retrieved" data: - $ref: '#/components/schemas/ImportValidationResult' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/Totals' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /customers/import/confirm: - post: - operationId: confirmCustomersImport + /resellers/trend: + get: + operationId: getResellersTrend tags: - - Backend - Customers Import/Export - summary: /customers/import/confirm - Execute validated customer import - description: | - Confirm and execute a previously validated customer import. Calls `POST /customers` - (CREATE) for every `valid` row. - - **Inputs** - - - `import_id` — from the `/validate` response. Must still be valid (sessions expire after 30 min). - - `override` — **no-op for customers**: validate never emits `warning` rows since the - DB enforces no uniqueness on customer name or VAT, so there is nothing to UPDATE. - - `resolutions` — not applicable to customers. - - **Behaviour per row status** - - | status | confirm action | - |--------|----------------| - | `valid` | CREATE | - | `error` | skipped (`reason: error`) | - - **Atomicity** — non-atomic: failures during creation are reported per-row; - rows that succeeded before the failure are kept. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ImportConfirmRequest' + - Backend - Resellers Stats + summary: /resellers/trend - Get resellers trend data + description: Get trend analysis of resellers over time with cumulative counts (Owner + Distributor) + parameters: + - $ref: '#/components/parameters/TrendPeriodParam' responses: '200': - description: Import results + description: Resellers trend data retrieved successfully content: application/json: schema: @@ -7453,9 +8863,9 @@ paths: example: 200 message: type: string - example: "customers imported successfully" + example: "resellers trend retrieved successfully" data: - $ref: '#/components/schemas/ImportConfirmResult' + $ref: '#/components/schemas/TrendResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -7463,21 +8873,56 @@ paths: '403': $ref: '#/components/responses/Forbidden' - # =========================================================================== - # HEALTH CHECK - # =========================================================================== - /health: + /resellers/export: get: - operationId: getHealth + operationId: exportResellers tags: - - Backend - Health - - Collect - Health - summary: /health - Health check - description: Check if the service is healthy (available on both backend and collect servers) - security: [] + - Backend - Resellers Import/Export + summary: /resellers/export - Export resellers to CSV or PDF + description: Export resellers to CSV or PDF format with applied filters (max 10,000 resellers) + parameters: + - name: format + in: query + required: true + description: Export format (csv or pdf) + schema: + type: string + enum: [csv, pdf] + example: "csv" + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/ResellerSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + - $ref: '#/components/parameters/OwnedByOrganizationFilterParam' + - $ref: '#/components/parameters/IncludeHierarchyParam' responses: '200': - description: Service is healthy + description: Resellers exported successfully + content: + text/csv: + schema: + type: string + format: binary + application/pdf: + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /roles: + get: + operationId: getRoles + tags: + - Backend - Metadata + summary: /roles - Get all user roles + description: Get all available user roles with their IDs and descriptions + responses: + '200': + description: Roles retrieved successfully content: application/json: schema: @@ -7488,13 +8933,69 @@ paths: example: 200 message: type: string - example: "service healthy" + example: "roles retrieved successfully" data: type: object - description: Health data (collect includes worker metrics) - nullable: true - '404': - description: Endpoint not found + properties: + roles: + type: array + items: + $ref: '#/components/schemas/Role' + '401': + $ref: '#/components/responses/Unauthorized' + + # =========================================================================== + # SYSTEMS MANAGEMENT + # =========================================================================== + /systems: + get: + operationId: getSystems + tags: + - Backend - Systems + summary: /systems - List systems + description: | + Get list of systems visible to the user based on hierarchical organization permissions. Supports filtering by name, type, creator, version, organization, and status. + + **Query String Examples:** + + 1. **Single type filter**: `?type=nsec` + 2. **Multiple types filter**: `?type=nsec&type=ns8` + 3. **Multiple filters combined**: `?type=nsec&status=active&status=deleted&version=8.0` + 4. **With pagination and sorting**: `?page=1&page_size=50&sort_by=name&sort_direction=asc&type=nsec` + 5. **Search with filters**: `?search=backup&type=ns8&organization_id=org_abc123xyz` + 6. **Creator filter (by user)**: `?created_by=53h5zxpwu4vc` + 7. **Creator filter (by organization)**: `?created_by=lbswt1rxdhbz` + 8. **Creator filter (multiple)**: `?created_by=53h5zxpwu4vc&created_by=lbswt1rxdhbz` + + **Complete Example Request:** + ``` + GET /api/systems?page=1&page_size=20&sort_by=created_at&sort_direction=desc&type=nsec&type=ns8&status=active&version=nsec:8.0&version=ns8:1.2.3&organization_id=org_abc123xyz + ``` + + This retrieves systems that are: + - Type: nsec OR ns8 + - Status: active + - Version: (nsec version 8.0) OR (ns8 version 1.2.3) + - Organization: org_abc123xyz + - Sorted by creation date (newest first) + - Page 1 with 20 items per page + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/SystemSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + - $ref: '#/components/parameters/SystemNameFilterParam' + - $ref: '#/components/parameters/SystemKeyFilterParam' + - $ref: '#/components/parameters/SystemTypeFilterParam' + - $ref: '#/components/parameters/SystemCreatedByFilterParam' + - $ref: '#/components/parameters/SystemVersionFilterParam' + - $ref: '#/components/parameters/SystemOrganizationFilterParam' + - $ref: '#/components/parameters/IncludeHierarchyParam' + - $ref: '#/components/parameters/SystemStatusFilterParam' + responses: + '200': + description: Systems retrieved successfully content: application/json: schema: @@ -7502,47 +9003,77 @@ paths: properties: code: type: integer - example: 404 + example: 200 message: type: string - example: "endpoint not found" + example: "systems retrieved successfully" data: type: object - nullable: true - example: null - '500': - $ref: '#/components/responses/InternalServerError' - '503': - description: Service is unhealthy (collect service only) + properties: + systems: + type: array + items: + $ref: '#/components/schemas/System' + pagination: + $ref: '#/components/schemas/Pagination' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + post: + operationId: createSystem + tags: + - Backend - Systems + summary: /systems - Create system + description: Create a new system. Users can create systems for organizations they can manage based on hierarchical permissions. The organization_id must be specified in the request. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSystemInput' + responses: + '201': + description: System created successfully content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - - # =========================================================================== - # FILTERS - # =========================================================================== - /filters/systems: - get: - operationId: getSystemFilters - tags: - - Backend - Filters - summary: /filters/systems - Get aggregated system filters - description: | - Aggregated endpoint that returns products, creators and versions - for the systems views. Auth is checked once, then the three datasets - are fetched in parallel. Respects RBAC hierarchy - users only see - data from systems they can access. - - **Version Format**: Returns versions in prefixed format `product:version` - (e.g., `"nsec:1.2.3"`, `"ns8:1.2.3"`) to avoid ambiguity when the same - version number exists for multiple products. + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "system created successfully" + data: + $ref: '#/components/schemas/System' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' - Organizations dropdown is populated by the dedicated `/api/organizations` - endpoint, which supports search and pagination. + /systems/{id}: + get: + operationId: getSystemById + tags: + - Backend - Systems + summary: /systems/{id} - Get single system + description: Get a specific system by ID. Users can access systems created by their organization based on hierarchical permissions. + parameters: + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" responses: '200': - description: System filters retrieved successfully + description: System retrieved successfully content: application/json: schema: @@ -7553,91 +9084,39 @@ paths: example: 200 message: type: string - example: "system filters retrieved successfully" + example: "system retrieved successfully" data: - type: object - properties: - products: - type: array - items: - type: string - description: List of unique product names (system types) - created_by: - type: array - items: - type: object - properties: - user_id: - type: string - description: User logto_id from created_by JSONB field (not UUID) - example: "pnqjmgmk937x" - name: - type: string - description: User full name from created_by JSONB field - example: "Company Owner" - email: - type: string - description: User email from created_by JSONB field - example: "owner@example.com" - organization_id: - type: string - description: Creator organization logto_id from created_by JSONB field - example: "2wl3iixbc8ua" - organization_name: - type: string - description: Creator organization name from created_by JSONB field - example: "Nethesis Italia" - description: List of users who created systems (one entry per user; organization attached to disambiguate homonyms) - versions: - type: array - items: - type: object - properties: - product: - type: string - description: Product type name - example: "NethSecurity" - versions: - type: array - items: - type: string - description: List of versions in prefixed format (product:version) - example: ["nsec:8.6.0", "nsec:8.5.2"] - description: Versions grouped by product type - example: - code: 200 - message: "system filters retrieved successfully" - data: - products: ["NethSecurity", "NethServer"] - created_by: - - user_id: "pnqjmgmk937x" - name: "Company Owner" - email: "owner@example.com" - organization_id: "2wl3iixbc8ua" - organization_name: "Nethesis Italia" - versions: - - product: "nsec" - versions: ["nsec:8.6.0", "nsec:8.5.2"] - - product: "ns8" - versions: ["ns8:1.2.3", "ns8:1.1.0"] + $ref: '#/components/schemas/System' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - # =========================================================================== - # ROLES & ORGANIZATIONS - # =========================================================================== - /organization-roles: - get: - operationId: getOrganizationRoles + put: + operationId: updateSystem tags: - - Backend - Metadata - summary: /organization-roles - Get all organization roles - description: Get all available organization roles with their IDs and descriptions + - Backend - Systems + summary: /systems/{id} - Update system + description: Update a system. All fields are optional. Users can update systems created by their organization based on hierarchical permissions. + parameters: + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSystemInput' responses: '200': - description: Organization roles retrieved successfully + description: System updated successfully content: application/json: schema: @@ -7648,62 +9127,35 @@ paths: example: 200 message: type: string - example: "organization roles retrieved successfully" + example: "system updated successfully" data: - type: object - properties: - organizationRoles: - type: array - items: - $ref: '#/components/schemas/OrganizationRole' + $ref: '#/components/schemas/System' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /organizations: - get: - operationId: getOrganizations + delete: + operationId: deleteSystem tags: - - Backend - Metadata - summary: /organizations - Get available organizations - description: | - Paginated list of organizations across the caller's hierarchy - (distributors + resellers + customers). Supports text search and - per-field filters; intended for filter dropdowns and assignment - pickers. + - Backend - Systems + summary: /systems/{id} - Delete system + description: Delete a system. Users can delete systems created by their organization based on hierarchical permissions. parameters: - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/OrganizationSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - - name: name - in: query - required: false - description: Filter by organization name (exact match) - schema: - type: string - - name: description - in: query - required: false - description: Filter by organization description (exact match) - schema: - type: string - - name: type - in: query - required: false - description: Filter by organization type - schema: - type: string - enum: [distributor, reseller, customer] - - name: created_by - in: query - required: false - description: Filter by creator user ID + - name: id + in: path + required: true + description: System ID schema: type: string + example: "sys_123456789" responses: '200': - description: Organizations retrieved successfully + description: System deleted successfully content: application/json: schema: @@ -7714,42 +9166,53 @@ paths: example: 200 message: type: string - example: "organizations retrieved successfully" + example: "system deleted successfully" data: type: object - properties: - organizations: - type: array - items: - $ref: '#/components/schemas/OrganizationReference' - pagination: - $ref: '#/components/schemas/Pagination' + nullable: true + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - # =========================================================================== - # BUSINESS HIERARCHY - RESELLERS - # =========================================================================== - /resellers: - get: - operationId: getResellers + /systems/{id}/restore: + patch: + operationId: restoreSystem tags: - - Backend - Resellers - summary: /resellers - List resellers - description: Get paginated list of resellers (Owner + Distributor) + - Backend - Systems + summary: /systems/{id}/restore - Restore soft-deleted system + description: Restore a soft-deleted system. Users can restore systems they have permission to delete based on hierarchical permissions. parameters: - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/ResellerSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - - $ref: '#/components/parameters/OrganizationStatusFilterParam' - - $ref: '#/components/parameters/OrgCreatedByFilterParam' - - $ref: '#/components/parameters/OwnedByOrganizationFilterParam' - - $ref: '#/components/parameters/IncludeHierarchyParam' + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" responses: '200': - description: Resellers retrieved successfully + description: System restored successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "system restored successfully" + data: + type: object + nullable: true + '400': + description: Bad request - System is not deleted content: application/json: schema: @@ -7757,39 +9220,38 @@ paths: properties: code: type: integer - example: 200 + example: 400 message: type: string - example: "resellers retrieved successfully" + example: "system is not deleted" data: type: object - properties: - resellers: - type: array - items: - $ref: '#/components/schemas/OrganizationWithStats' - pagination: - $ref: '#/components/schemas/Pagination' + nullable: true '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - post: - operationId: createReseller + /systems/{id}/destroy: + delete: + operationId: destroySystem tags: - - Backend - Resellers - summary: /resellers - Create reseller - description: Create a new reseller organization (Owner + Distributor) - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/OrganizationInput' + - Backend - Systems + summary: /systems/{id}/destroy - Permanently delete system + description: Permanently delete a system (requires destroy:systems permission and hierarchical validation) + parameters: + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" responses: - '201': - description: Reseller created successfully + '200': + description: System permanently destroyed content: application/json: schema: @@ -7797,39 +9259,38 @@ paths: properties: code: type: integer - example: 201 + example: 200 message: type: string - example: "reseller created successfully" + example: "system permanently destroyed" data: - $ref: '#/components/schemas/Organization' - '400': - $ref: '#/components/responses/BadRequest' + type: object + nullable: true '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '422': - $ref: '#/components/responses/UnprocessableEntity' + '404': + $ref: '#/components/responses/NotFound' - /resellers/{id}: - get: - operationId: getResellerById + /systems/{id}/suspend: + patch: + operationId: suspendSystem tags: - - Backend - Resellers - summary: /resellers/{id} - Get single reseller - description: Get a specific reseller by ID (Owner + Distributor) + - Backend - Systems + summary: /systems/{id}/suspend - Suspend system + description: Suspend a system (requires manage:systems permission and hierarchical validation) parameters: - name: id in: path required: true - description: Reseller Logto ID + description: System ID schema: type: string - example: "jf584cz36kce" + example: "sys_123456789" responses: '200': - description: Reseller retrieved successfully + description: System suspended successfully content: application/json: schema: @@ -7840,9 +9301,12 @@ paths: example: 200 message: type: string - example: "reseller retrieved successfully" + example: "system suspended successfully" data: - $ref: '#/components/schemas/Organization' + type: object + nullable: true + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -7850,29 +9314,24 @@ paths: '404': $ref: '#/components/responses/NotFound' - put: - operationId: updateReseller + /systems/{id}/reactivate: + patch: + operationId: reactivateSystem tags: - - Backend - Resellers - summary: /resellers/{id} - Update reseller - description: Update a reseller organization (Owner + Distributor) + - Backend - Systems + summary: /systems/{id}/reactivate - Reactivate suspended system + description: Reactivate a suspended system (requires manage:systems permission and hierarchical validation) parameters: - name: id in: path required: true - description: Reseller Logto ID + description: System ID schema: type: string - example: "jf584cz36kce" - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/OrganizationInput' + example: "sys_123456789" responses: '200': - description: Reseller updated successfully + description: System reactivated successfully content: application/json: schema: @@ -7883,9 +9342,10 @@ paths: example: 200 message: type: string - example: "reseller updated successfully" + example: "system reactivated successfully" data: - $ref: '#/components/schemas/Organization' + type: object + nullable: true '400': $ref: '#/components/responses/BadRequest' '401': @@ -7894,26 +9354,25 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '422': - $ref: '#/components/responses/UnprocessableEntity' - delete: - operationId: deleteReseller + /systems/{id}/regenerate-secret: + post: + operationId: regenerateSystemSecret tags: - - Backend - Resellers - summary: /resellers/{id} - Delete reseller - description: Delete a reseller organization (Owner + Distributor) + - Backend - Systems + summary: /systems/{id}/regenerate-secret - Regenerate system secret + description: Regenerate authentication secret for a specific system. Users can regenerate secrets for systems created by their organization based on hierarchical permissions. parameters: - name: id in: path required: true - description: Reseller Logto ID + description: System ID schema: type: string - example: "jf584cz36kce" + example: "sys_123456789" responses: '200': - description: Reseller deleted successfully + description: System secret regenerated successfully content: application/json: schema: @@ -7924,20 +9383,9 @@ paths: example: 200 message: type: string - example: "reseller deleted successfully" + example: "system secret regenerated successfully" data: - type: object - properties: - deleted_systems_count: - type: integer - description: Number of systems cascade soft-deleted across the reseller hierarchy - example: 3 - deleted_users_count: - type: integer - description: Number of users cascade soft-deleted across the reseller hierarchy - example: 8 - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/System' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -7945,23 +9393,30 @@ paths: '404': $ref: '#/components/responses/NotFound' - /resellers/{id}/stats: + /systems/{id}/reachability: get: - operationId: getResellerStats + operationId: checkSystemReachability tags: - - Backend - Resellers - summary: /resellers/{id}/stats - Get reseller statistics - description: Get users, systems and customers count for a specific reseller (Owner + Distributor + own Reseller) + - Backend - Systems + summary: /systems/{id}/reachability - Check if system web UI is reachable + description: | + Checks if the system's web UI is reachable from the backend server. + The target URL is constructed from the system's FQDN and type: + - NS8: `https://{fqdn}/cluster-admin` + - NethSecurity: tries `https://{fqdn}:443` first, then `https://{fqdn}:9090` + + Returns `reachable: true` with the responding `url` on success, or `reachable: false` if unreachable. parameters: - name: id in: path required: true + description: System ID schema: type: string - description: Reseller Logto ID + example: "sys_123456789" responses: '200': - description: Reseller stats retrieved successfully + description: Reachability check completed content: application/json: schema: @@ -7972,9 +9427,17 @@ paths: example: 200 message: type: string - example: "reseller stats retrieved successfully" + example: "reachability check completed" data: - $ref: '#/components/schemas/ResellerStats' + type: object + properties: + reachable: + type: boolean + example: true + url: + type: string + description: The URL that responded successfully (empty if not reachable) + example: "https://firewall.example.com:9090" '400': $ref: '#/components/responses/BadRequest' '401': @@ -7984,23 +9447,82 @@ paths: '404': $ref: '#/components/responses/NotFound' - /resellers/{id}/suspend: - patch: - operationId: suspendReseller + /systems/register: + post: + operationId: registerSystem tags: - - Backend - Resellers - summary: /resellers/{id}/suspend - Suspend reseller - description: Suspend a reseller and all its users (cascade suspension). Owner and Distributor (hierarchical) can suspend. - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Reseller Logto ID + - Backend - Systems + security: [] + summary: /systems/register - Register system with system_secret + description: | + Public endpoint for external systems to register themselves using their system_secret token. + This endpoint does NOT require authentication - the system_secret itself provides authentication. + + **Token Format:** `my_.` + + **Workflow:** + 1. User creates system in management UI → receives system_secret (one time only) + 2. External system uses system_secret to register → receives system_key + 3. External system uses system_key for future operations (inventory, heartbeat) + + **Important:** + - Systems can only be registered once + - Deleted systems cannot be registered + - The system_secret is validated using SHA256 hashing + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - system_secret + properties: + system_secret: + type: string + description: System secret token in format my_. + example: "my_a1b2c3d4e5f6g7h8i9j0.k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0" responses: '200': - description: Reseller suspended successfully + description: System registered successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "system registered successfully" + data: + type: object + properties: + system_key: + type: string + description: System key to use for future operations + example: "my_sys_a1b2c3d4e5f6g7h8" + registered_at: + type: string + format: date-time + description: Timestamp when system was registered + example: "2025-11-06T10:30:00Z" + '400': + description: Invalid system secret format + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 400 + message: + type: string + example: "invalid system secret format" + '401': + description: Invalid system secret (authentication failed) content: application/json: schema: @@ -8008,53 +9530,12 @@ paths: properties: code: type: integer - example: 200 + example: 401 message: type: string - example: "reseller suspended successfully" - data: - type: object - properties: - reseller: - $ref: '#/components/schemas/Organization' - suspended_customers_count: - type: integer - example: 5 - description: Number of customers that were cascade-suspended - suspended_users_count: - type: integer - example: 15 - description: Number of users that were cascade-suspended - suspended_systems_count: - type: integer - example: 10 - description: Number of systems that were cascade-suspended - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' + example: "invalid system secret" '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - - /resellers/{id}/reactivate: - patch: - operationId: reactivateReseller - tags: - - Backend - Resellers - summary: /resellers/{id}/reactivate - Reactivate reseller - description: Reactivate a suspended reseller and all its cascade-suspended users. Owner and Distributor (hierarchical) can reactivate. - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Reseller Logto ID - responses: - '200': - description: Reseller reactivated successfully + description: System has been deleted content: application/json: schema: @@ -8062,54 +9543,12 @@ paths: properties: code: type: integer - example: 200 + example: 403 message: type: string - example: "reseller reactivated successfully" - data: - type: object - properties: - reseller: - $ref: '#/components/schemas/Organization' - reactivated_customers_count: - type: integer - example: 5 - description: Number of customers that were cascade-reactivated - reactivated_users_count: - type: integer - example: 15 - description: Number of users that were cascade-reactivated - reactivated_systems_count: - type: integer - example: 10 - description: Number of systems that were cascade-reactivated - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - - /resellers/{id}/restore: - patch: - operationId: restoreReseller - tags: - - Backend - Resellers - summary: /resellers/{id}/restore - Restore soft-deleted reseller - description: Restore a soft-deleted reseller (requires manage:resellers permission and hierarchical validation) - parameters: - - name: id - in: path - required: true - description: Reseller Logto ID - schema: - type: string - example: "jf584cz36kce" - responses: - '200': - description: Reseller restored successfully + example: "system has been deleted" + '409': + description: System is already registered content: application/json: schema: @@ -8117,23 +9556,33 @@ paths: properties: code: type: integer - example: 200 + example: 409 message: type: string - example: "reseller restored successfully" - data: - type: object - properties: - restored_systems_count: - type: integer - description: Number of systems cascade restored across the reseller hierarchy - example: 3 - restored_users_count: - type: integer - description: Number of users cascade restored across the reseller hierarchy - example: 8 - '400': - description: Bad request - Reseller is not deleted + example: "system is already registered" + '500': + $ref: '#/components/responses/InternalServerError' + + /systems/totals: + get: + operationId: getSystemsTotals + tags: + - Backend - Systems Stats + summary: /systems/totals - Get systems status summary + description: Get systems heartbeat status summary for dashboard (requires Admin or Support role) + parameters: + - name: timeout + in: query + description: Timeout in minutes for active/inactive determination + schema: + type: integer + minimum: 1 + maximum: 60 + default: 15 + example: 15 + responses: + '200': + description: Systems status summary retrieved successfully content: application/json: schema: @@ -8141,38 +9590,29 @@ paths: properties: code: type: integer - example: 400 + example: 200 message: type: string - example: "reseller is not deleted" + example: "system status retrieved" data: - type: object - nullable: true + $ref: '#/components/schemas/SystemTotals' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /resellers/{id}/destroy: - delete: - operationId: destroyReseller + /systems/trend: + get: + operationId: getSystemsTrend tags: - - Backend - Resellers - summary: /resellers/{id}/destroy - Permanently delete reseller - description: Permanently delete a reseller organization (requires destroy:resellers permission and hierarchical validation) + - Backend - Systems Stats + summary: /systems/trend - Get systems trend data + description: Get trend analysis of systems over time with cumulative counts parameters: - - name: id - in: path - required: true - description: Reseller Logto ID - schema: - type: string - example: "jf584cz36kce" + - $ref: '#/components/parameters/TrendPeriodParam' responses: '200': - description: Reseller permanently destroyed + description: Systems trend data retrieved successfully content: application/json: schema: @@ -8183,27 +9623,87 @@ paths: example: 200 message: type: string - example: "reseller permanently destroyed" + example: "systems trend retrieved successfully" data: - type: object - nullable: true + $ref: '#/components/schemas/TrendResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /resellers/totals: + /systems/export: get: - operationId: getResellersTotals + operationId: exportSystems tags: - - Backend - Resellers Stats - summary: /resellers/totals - Get resellers totals - description: Get total count of resellers accessible to the user (Owner + Distributor) + - Backend - Systems Stats + summary: /systems/export - Export systems to CSV or PDF + description: | + Export systems with applied filters to CSV or PDF format. + + **Features:** + - Supports all filter parameters from GET /systems + - Maximum 10,000 systems per export (DoS protection) + - CSV format: Simple spreadsheet with all fields + - PDF format: Formatted report with metadata and filters applied + - File naming: `systems_export_YYYY-MM-DD_HHmmss.[csv|pdf]` + + **Permissions**: Requires `read:systems` permission and respects RBAC hierarchy + + **Export Fields:** + - Name, Type, Version, Status + - FQDN, IPv4 Address, IPv6 Address + - System Key, Notes + - Created At + - Organization Name, Organization Type + - Created By Name, Created By Email, Created By Organization + parameters: + - name: format + in: query + required: true + description: Export format (csv or pdf) + schema: + type: string + enum: [csv, pdf] + example: csv + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/SystemSortByParam' + - $ref: '#/components/parameters/SortDirectionParam' + - $ref: '#/components/parameters/SystemNameFilterParam' + - $ref: '#/components/parameters/SystemKeyFilterParam' + - $ref: '#/components/parameters/SystemTypeFilterParam' + - $ref: '#/components/parameters/SystemCreatedByFilterParam' + - $ref: '#/components/parameters/SystemVersionFilterParam' + - $ref: '#/components/parameters/SystemOrganizationFilterParam' + - $ref: '#/components/parameters/IncludeHierarchyParam' + - $ref: '#/components/parameters/SystemStatusFilterParam' responses: '200': - description: Resellers totals retrieved successfully + description: Export file generated successfully + content: + text/csv: + schema: + type: string + format: binary + description: CSV file with systems data + application/pdf: + schema: + type: string + format: binary + description: PDF report with systems data + headers: + Content-Disposition: + description: Attachment filename + schema: + type: string + example: 'attachment; filename="systems_export_2025-01-26_153045.csv"' + Content-Length: + description: File size in bytes + schema: + type: integer + '400': + description: Bad request (invalid format or too many systems) content: application/json: schema: @@ -8211,29 +9711,60 @@ paths: properties: code: type: integer - example: 200 + example: 400 message: type: string - example: "resellers totals retrieved" + example: "too many systems to export (15000). maximum allowed: 10000. please apply more filters" data: - $ref: '#/components/schemas/Totals' + type: object + properties: + total_count: + type: integer + example: 15000 + max_limit: + type: integer + example: 10000 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /resellers/trend: + /systems/{id}/inventory: get: - operationId: getResellersTrend + operationId: getSystemInventory tags: - - Backend - Resellers Stats - summary: /resellers/trend - Get resellers trend data - description: Get trend analysis of resellers over time with cumulative counts (Owner + Distributor) + - Backend - Systems Inventory + summary: /systems/{id}/inventory - Get system inventory history + description: Get paginated inventory history for a specific system. Supports date range filtering. parameters: - - $ref: '#/components/parameters/TrendPeriodParam' + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - name: from_date + in: query + required: false + description: Filter records from this date (RFC3339 format) + schema: + type: string + format: date-time + example: "2026-01-01T00:00:00Z" + - name: to_date + in: query + required: false + description: Filter records up to this date (RFC3339 format) + schema: + type: string + format: date-time + example: "2026-02-20T23:59:59Z" responses: '200': - description: Resellers trend data retrieved successfully + description: Inventory history retrieved successfully content: application/json: schema: @@ -8244,66 +9775,41 @@ paths: example: 200 message: type: string - example: "resellers trend retrieved successfully" + example: "inventory history retrieved successfully" data: - $ref: '#/components/schemas/TrendResponse' - '400': - $ref: '#/components/responses/BadRequest' + type: object + properties: + records: + type: array + items: + $ref: '#/components/schemas/InventoryRecord' + pagination: + $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /resellers/export: + /systems/{id}/inventory/latest: get: - operationId: exportResellers + operationId: getSystemInventoryLatest tags: - - Backend - Resellers Import/Export - summary: /resellers/export - Export resellers to CSV or PDF - description: Export resellers to CSV or PDF format with applied filters (max 10,000 resellers) + - Backend - Systems Inventory + summary: /systems/{id}/inventory/latest - Get latest system inventory + description: Get the most recent inventory data for a specific system. Returns the full InventoryRecord including the raw inventory JSON in the `data` field. parameters: - - name: format - in: query + - name: id + in: path required: true - description: Export format (csv or pdf) + description: System ID schema: type: string - enum: [csv, pdf] - example: "csv" - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/ResellerSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - - $ref: '#/components/parameters/OwnedByOrganizationFilterParam' - - $ref: '#/components/parameters/IncludeHierarchyParam' - responses: - '200': - description: Resellers exported successfully - content: - text/csv: - schema: - type: string - format: binary - application/pdf: - schema: - type: string - format: binary - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /roles: - get: - operationId: getRoles - tags: - - Backend - Metadata - summary: /roles - Get all user roles - description: Get all available user roles with their IDs and descriptions + example: "sys_123456789" responses: '200': - description: Roles retrieved successfully + description: Latest inventory retrieved successfully. Returns null when no inventory is available (not an error condition). content: application/json: schema: @@ -8314,69 +9820,44 @@ paths: example: 200 message: type: string - example: "roles retrieved successfully" + example: "latest inventory retrieved successfully" data: type: object - properties: - roles: - type: array - items: - $ref: '#/components/schemas/Role' + nullable: true + description: Latest inventory record, or null if no inventory exists + allOf: + - $ref: '#/components/schemas/InventoryRecord' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' - # =========================================================================== - # SYSTEMS MANAGEMENT - # =========================================================================== - /systems: + /systems/{id}/inventory/{inventory_id}: get: - operationId: getSystems + operationId: getSystemInventoryByID tags: - - Backend - Systems - summary: /systems - List systems - description: | - Get list of systems visible to the user based on hierarchical organization permissions. Supports filtering by name, type, creator, version, organization, and status. - - **Query String Examples:** - - 1. **Single type filter**: `?type=nsec` - 2. **Multiple types filter**: `?type=nsec&type=ns8` - 3. **Multiple filters combined**: `?type=nsec&status=active&status=deleted&version=8.0` - 4. **With pagination and sorting**: `?page=1&page_size=50&sort_by=name&sort_direction=asc&type=nsec` - 5. **Search with filters**: `?search=backup&type=ns8&organization_id=org_abc123xyz` - 6. **Creator filter (by user)**: `?created_by=53h5zxpwu4vc` - 7. **Creator filter (by organization)**: `?created_by=lbswt1rxdhbz` - 8. **Creator filter (multiple)**: `?created_by=53h5zxpwu4vc&created_by=lbswt1rxdhbz` - - **Complete Example Request:** - ``` - GET /api/systems?page=1&page_size=20&sort_by=created_at&sort_direction=desc&type=nsec&type=ns8&status=active&version=nsec:8.0&version=ns8:1.2.3&organization_id=org_abc123xyz - ``` - - This retrieves systems that are: - - Type: nsec OR ns8 - - Status: active - - Version: (nsec version 8.0) OR (ns8 version 1.2.3) - - Organization: org_abc123xyz - - Sorted by creation date (newest first) - - Page 1 with 20 items per page + - Backend - Systems Inventory + summary: /systems/{id}/inventory/{inventory_id} - Get a specific inventory record + description: Get a specific inventory record by its ID, scoped to the system. parameters: - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/SystemSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - - $ref: '#/components/parameters/SystemNameFilterParam' - - $ref: '#/components/parameters/SystemKeyFilterParam' - - $ref: '#/components/parameters/SystemTypeFilterParam' - - $ref: '#/components/parameters/SystemCreatedByFilterParam' - - $ref: '#/components/parameters/SystemVersionFilterParam' - - $ref: '#/components/parameters/SystemOrganizationFilterParam' - - $ref: '#/components/parameters/IncludeHierarchyParam' - - $ref: '#/components/parameters/SystemStatusFilterParam' + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" + - name: inventory_id + in: path + required: true + description: Inventory record ID + schema: + type: integer + format: int64 + example: 42 responses: '200': - description: Systems retrieved successfully + description: Inventory record retrieved successfully content: application/json: schema: @@ -8387,36 +9868,36 @@ paths: example: 200 message: type: string - example: "systems retrieved successfully" + example: "inventory record retrieved successfully" data: - type: object - properties: - systems: - type: array - items: - $ref: '#/components/schemas/System' - pagination: - $ref: '#/components/schemas/Pagination' + $ref: '#/components/schemas/InventoryRecord' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - post: - operationId: createSystem + /systems/{id}/inventory/changes: + get: + operationId: getSystemInventoryChanges tags: - - Backend - Systems - summary: /systems - Create system - description: Create a new system. Users can create systems for organizations they can manage based on hierarchical permissions. The organization_id must be specified in the request. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSystemInput' + - Backend - Systems Inventory + summary: /systems/{id}/inventory/changes - Get inventory changes summary + description: Get an aggregated summary of all inventory changes for a specific system. Returns counts grouped by category and severity, not a paginated list. + parameters: + - name: id + in: path + required: true + description: System ID + schema: + type: string + example: "sys_123456789" responses: - '201': - description: System created successfully + '200': + description: Changes summary retrieved successfully. Returns null when no inventory is available (not an error condition). content: application/json: schema: @@ -8424,26 +9905,28 @@ paths: properties: code: type: integer - example: 201 + example: 200 message: type: string - example: "system created successfully" + example: "changes summary retrieved successfully" data: - $ref: '#/components/schemas/System' - '400': - $ref: '#/components/responses/BadRequest' + type: object + nullable: true + description: Aggregated changes summary, or null if no inventory exists + allOf: + - $ref: '#/components/schemas/InventoryChangesSummary' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /systems/{id}: + /systems/{id}/inventory/changes/latest: get: - operationId: getSystemById + operationId: getSystemInventoryChangesLatest tags: - - Backend - Systems - summary: /systems/{id} - Get single system - description: Get a specific system by ID. Users can access systems created by their organization based on hierarchical permissions. + - Backend - Systems Inventory + summary: /systems/{id}/inventory/changes/latest - Get latest inventory changes + description: Get the changes summary scoped to the most recent inventory batch only. The `recent_changes` field equals `total_changes` since all changes are from the latest batch. parameters: - name: id in: path @@ -8454,7 +9937,7 @@ paths: example: "sys_123456789" responses: '200': - description: System retrieved successfully + description: Latest inventory changes summary retrieved successfully. Returns null when no inventory is available (not an error condition). content: application/json: schema: @@ -8465,22 +9948,25 @@ paths: example: 200 message: type: string - example: "system retrieved successfully" + example: "latest inventory changes summary retrieved successfully" data: - $ref: '#/components/schemas/System' + type: object + nullable: true + description: Changes summary for the latest inventory batch, or null if no inventory exists + allOf: + - $ref: '#/components/schemas/InventoryChangesSummary' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - put: - operationId: updateSystem + /systems/{id}/inventory/diffs: + get: + operationId: getSystemInventoryDiffs tags: - - Backend - Systems - summary: /systems/{id} - Update system - description: Update a system. All fields are optional. Users can update systems created by their organization based on hierarchical permissions. + - Backend - Systems Inventory + summary: /systems/{id}/inventory/diffs - Get inventory diffs + description: Get paginated inventory diffs for a specific system. Supports filtering by severity, category, diff type, date range, and text search on field path and values. parameters: - name: id in: path @@ -8489,15 +9975,83 @@ paths: schema: type: string example: "sys_123456789" - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateSystemInput' + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - name: search + in: query + required: false + description: "Case-insensitive text search across field_path, previous_value, and current_value." + schema: + type: string + minLength: 1 + example: "network" + - name: severity + in: query + required: false + description: Filter by severity level. Supports multiple values. + schema: + type: array + items: + type: string + enum: [low, medium, high, critical] + example: ["high", "critical"] + style: form + explode: true + - name: category + in: query + required: false + description: Filter by change category. Supports multiple values. + schema: + type: array + items: + type: string + enum: [os, hardware, network, security, backup, features, modules, cluster, nodes, system] + example: ["os", "network"] + style: form + explode: true + - name: diff_type + in: query + required: false + description: Filter by diff type. Supports multiple values. + schema: + type: array + items: + type: string + enum: [create, update, delete] + example: ["create", "update"] + style: form + explode: true + - name: inventory_id + in: query + required: false + description: Filter by specific inventory record IDs. Supports multiple values. Use the `inventory_ids` from a `/timeline` group to fetch diffs for that date group. + schema: + type: array + items: + type: integer + format: int64 + example: [42, 43] + style: form + explode: true + - name: from_date + in: query + required: false + description: Filter diffs from this date (RFC3339 format) + schema: + type: string + format: date-time + example: "2026-01-01T00:00:00Z" + - name: to_date + in: query + required: false + description: Filter diffs up to this date (RFC3339 format) + schema: + type: string + format: date-time + example: "2026-02-20T23:59:59Z" responses: '200': - description: System updated successfully + description: Inventory diffs retrieved successfully content: application/json: schema: @@ -8508,11 +10062,16 @@ paths: example: 200 message: type: string - example: "system updated successfully" + example: "inventory diffs retrieved successfully" data: - $ref: '#/components/schemas/System' - '400': - $ref: '#/components/responses/BadRequest' + type: object + properties: + diffs: + type: array + items: + $ref: '#/components/schemas/InventoryDiff' + pagination: + $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -8520,12 +10079,13 @@ paths: '404': $ref: '#/components/responses/NotFound' - delete: - operationId: deleteSystem + /systems/{id}/inventory/diffs/latest: + get: + operationId: getSystemInventoryDiffsLatest tags: - - Backend - Systems - summary: /systems/{id} - Delete system - description: Delete a system. Users can delete systems created by their organization based on hierarchical permissions. + - Backend - Systems Inventory + summary: /systems/{id}/inventory/diffs/latest - Get latest inventory diffs batch + description: Get all diffs from the most recent inventory snapshot. Returns the complete batch (not paginated), ordered by severity (critical first) then by creation time descending. parameters: - name: id in: path @@ -8536,7 +10096,7 @@ paths: example: "sys_123456789" responses: '200': - description: System deleted successfully + description: Latest inventory diffs retrieved successfully content: application/json: schema: @@ -8547,12 +10107,24 @@ paths: example: 200 message: type: string - example: "system deleted successfully" + example: "latest inventory diffs retrieved successfully" data: type: object - nullable: true - '400': - $ref: '#/components/responses/BadRequest' + description: Latest diffs batch data + properties: + diffs: + type: array + items: + $ref: '#/components/schemas/InventoryDiff' + count: + type: integer + description: Number of diffs in this batch + example: 5 + current_inventory_id: + type: integer + format: int64 + description: ID of the current inventory record these diffs belong to. Only present when there are diffs. + example: 42 '401': $ref: '#/components/responses/Unauthorized' '403': @@ -8560,13 +10132,21 @@ paths: '404': $ref: '#/components/responses/NotFound' - /systems/{id}/restore: - patch: - operationId: restoreSystem + /systems/{id}/inventory/timeline: + get: + operationId: getSystemInventoryTimeline tags: - - Backend - Systems - summary: /systems/{id}/restore - Restore soft-deleted system - description: Restore a soft-deleted system. Users can restore systems they have permission to delete based on hierarchical permissions. + - Backend - Systems Inventory + summary: /systems/{id}/inventory/timeline - Get inventory timeline grouped by date + description: | + Get a date-grouped inventory timeline for a specific system. + Returns a filtered summary (severity counters) and groups of inventory dates. + Each group includes `inventory_ids` — the IDs of inventory records collected that day. + Groups with no matching diffs still appear with `change_count: 0`, allowing the UI + to display "X inventories, no changes" badges. Pagination is over date groups. + To fetch the actual diffs for a group, call `GET /systems/{id}/inventory/diffs?inventory_id=42&inventory_id=43` + with the `inventory_ids` from the group. + Supports text search on diff field paths and values. parameters: - name: id in: path @@ -8575,25 +10155,71 @@ paths: schema: type: string example: "sys_123456789" - responses: - '200': - description: System restored successfully - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "system restored successfully" - data: - type: object - nullable: true - '400': - description: Bad request - System is not deleted + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PageSizeParam' + - name: search + in: query + required: false + description: "Case-insensitive text search across diff field_path, previous_value, and current_value. Affects change_count in groups and summary counters." + schema: + type: string + minLength: 1 + example: "dhcp" + - name: severity + in: query + required: false + description: Filter by severity level. Supports multiple values. + schema: + type: array + items: + type: string + enum: [low, medium, high, critical] + example: ["high", "critical"] + style: form + explode: true + - name: category + in: query + required: false + description: Filter by change category. Supports multiple values. + schema: + type: array + items: + type: string + enum: [os, hardware, network, security, backup, features, modules, cluster, nodes, system] + example: ["os", "security"] + style: form + explode: true + - name: diff_type + in: query + required: false + description: Filter by diff type. Supports multiple values. + schema: + type: array + items: + type: string + enum: [create, update, delete] + example: ["create", "update"] + style: form + explode: true + - name: from_date + in: query + required: false + description: Show dates from this timestamp (RFC3339 format) + schema: + type: string + format: date-time + example: "2026-01-01T00:00:00Z" + - name: to_date + in: query + required: false + description: Show dates up to this timestamp (RFC3339 format) + schema: + type: string + format: date-time + example: "2026-03-17T23:59:59Z" + responses: + '200': + description: Inventory timeline retrieved successfully content: application/json: schema: @@ -8601,13 +10227,21 @@ paths: properties: code: type: integer - example: 400 + example: 200 message: type: string - example: "system is not deleted" + example: "inventory timeline retrieved successfully" data: type: object - nullable: true + properties: + summary: + $ref: '#/components/schemas/InventoryTimelineSummary' + groups: + type: array + items: + $ref: '#/components/schemas/InventoryTimelineGroup' + pagination: + $ref: '#/components/schemas/Pagination' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -8615,24 +10249,143 @@ paths: '404': $ref: '#/components/responses/NotFound' - /systems/{id}/destroy: - delete: - operationId: destroySystem + /alerts: + get: + operationId: getAlerts tags: - - Backend - Systems - summary: /systems/{id}/destroy - Permanently delete system - description: Permanently delete a system (requires destroy:systems permission and hierarchical validation) + - Backend - Alerts + summary: "/alerts - List active alerts (cross-hierarchy)" + description: | + Retrieves active alerts from Mimir for the caller's scope, paginated. Each + alert is enriched with a `system` object (`name`, `type`) looked up from the + local `systems` table, so the UI can render the system column without an + extra round-trip per row. + + Sortable by `starts_at` (default desc), `severity` (criticality rank: critical + > warning > info), `alertname`, or `status` (Alertmanager state). `fingerprint` + is used as a stable tiebreaker so pagination doesn't shift between requests. + + Scope follows the same three modes as `/alerts/totals`: + - `organization_id` omitted → caller's full hierarchy (cross-tenant fan-out). + - `organization_id=X` → single tenant `X`. + - `organization_id=X&include=descendants` → `X` plus its sub-tree. + + All filter params support **multiple values** (repeat the param): values within + the same filter are matched as OR; different filters AND together. Example: + `?severity=critical&severity=warning&alertname=CVE-2024-1234` returns + CVE-2024-1234 alerts that are critical or warning. + + Per-tenant failures during fan-out (timeout, 5xx) are non-fatal: the rest of the + result is returned and the failure is reported in the `warnings` array. + security: + - BearerAuth: [] parameters: - - name: id - in: path - required: true - description: System ID + - name: organization_id + in: query + description: | + Target organization ID(s). Repeat the param to pass multiple values. + Optional for all roles except Customer (where it is ignored). + schema: + type: array + items: + type: string + style: form + explode: true + - name: include + in: query + description: Set to `descendants` together with `organization_id` to expand each value to its sub-tree. schema: type: string - example: "sys_123456789" + enum: [descendants] + - name: page + in: query + description: 1-based page number. + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: Page size. Default 50, max 100. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: sort_by + in: query + description: | + Sort column (allowlist). `assigned_user_name` sorts by the current + assignee's display name (case-insensitive; unassigned alerts group + together at one end) — active list only, history falls back to its + default. + schema: + type: string + enum: [starts_at, severity, alertname, status, assigned_user_name] + default: starts_at + - name: sort_direction + in: query + schema: + type: string + enum: [asc, desc] + default: desc + - name: status + in: query + description: Filter alerts by Alertmanager state. Supports multiple values. + schema: + type: array + items: + type: string + enum: [active, suppressed, unprocessed] + style: form + explode: true + - name: severity + in: query + description: Filter alerts by severity label. Supports multiple values. + schema: + type: array + items: + type: string + enum: [critical, warning, info] + style: form + explode: true + - name: system_key + in: query + description: Filter alerts by system_key label. Supports multiple values. + schema: + type: array + items: + type: string + style: form + explode: true + - name: alertname + in: query + description: | + Filter alerts by alertname label (the alert "type" — e.g. `HighCPU`, + `DiskFull`, `CVE-2024-1234`). Supports multiple values. + schema: + type: array + items: + type: string + style: form + explode: true + - name: assigned_user_id + in: query + description: | + Filter alerts by current assignee (logto_id of the user, as + returned in `assigned_to.user_id`). Supports multiple values. The + literal value `none` matches unassigned alerts, so + `assigned_user_id=&assigned_user_id=none` means "mine or up + for grabs". + schema: + type: array + items: + type: string + style: form + explode: true responses: '200': - description: System permanently destroyed + description: Paginated list of active alerts content: application/json: schema: @@ -8643,24 +10396,112 @@ paths: example: 200 message: type: string - example: "system permanently destroyed" + example: alerts retrieved successfully data: type: object - nullable: true + properties: + alerts: + type: array + items: + $ref: '#/components/schemas/ActiveAlert' + pagination: + $ref: '#/components/schemas/Pagination' + warnings: + type: array + description: | + Per-tenant errors encountered during fan-out. Always present + (empty array when every tenant responded OK). Each entry is + a string `org : `. + items: + type: string + examples: + ActiveAlertExample: + summary: One active warning across the caller's hierarchy + description: | + A single warning alert. System identity (id, key, name, type) is + carried as labels, stamped at ingest time. `state="active"` means + Mimir has not been told to silence it; an actively-muted alert + would have `state="suppressed"` and a non-empty `silencedBy`. + value: + code: 200 + message: alerts retrieved successfully + data: + alerts: + - fingerprint: "0a9d04bb6eed523f" + labels: + alertname: "DiskFilling" + severity: "warning" + system_id: "e4eb4844-46f6-448c-8279-7cfedf5e1037" + system_key: "NETH-D417-A2C2-7810-43D2-984B-2164-34C1-B22E" + system_name: "test-sys" + system_type: "ns8" + annotations: + summary: "/var is 92% full on test-sys" + description: "Disk usage exceeded the warning threshold." + status: + state: "active" + silencedBy: [] + inhibitedBy: [] + startsAt: "2026-05-12T08:14:00Z" + endsAt: "2026-05-12T08:44:00Z" + assigned_to: + user_id: "c5gpnoo2do48" + user_name: "R1C1 Admin" + user_org_id: "m4m3mdjdiizs" + user_org_name: "R1C1 Customer" + assigned_at: "2026-05-12T08:20:00Z" + pagination: + page: 1 + page_size: 50 + total_count: 1 + total_pages: 1 + has_next: false + has_prev: false + warnings: [] + PartialFanoutWarning: + summary: One tenant timed out during fan-out + description: | + When `organization_id` is omitted (or `include=descendants` is + used), the request fans out to every tenant in scope. A single + slow Mimir does not fail the whole request — the rest of the + results are returned and the failing tenant lands in `warnings`. + value: + code: 200 + message: alerts retrieved successfully + data: + alerts: [] + pagination: + page: 1 + page_size: 50 + total_count: 0 + total_pages: 0 + has_next: false + has_prev: false + warnings: + - "org pt8gqs6y5wpr: context deadline exceeded" + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/suspend: - patch: - operationId: suspendSystem + # =========================================================================== + # ENTITLEMENTS (Granular add-on licensing) + # =========================================================================== + + /systems/{id}/entitlements: + get: + operationId: listSystemEntitlements tags: - - Backend - Systems - summary: /systems/{id}/suspend - Suspend system - description: Suspend a system (requires manage:systems permission and hierarchical validation) + - Backend - Entitlements + summary: /systems/{id}/entitlements - List the add-on grants of a system + description: | + List the add-on grants of a system, including expired and revoked ones + (`active` tells them apart). Any user who can read the system can read + its entitlements. parameters: - name: id in: path @@ -8671,7 +10512,7 @@ paths: example: "sys_123456789" responses: '200': - description: System suspended successfully + description: Entitlements retrieved successfully content: application/json: schema: @@ -8682,26 +10523,36 @@ paths: example: 200 message: type: string - example: "system suspended successfully" + example: "entitlements retrieved successfully" data: type: object - nullable: true - '400': - $ref: '#/components/responses/BadRequest' + properties: + entitlements: + type: array + items: + $ref: '#/components/schemas/SystemEntitlement' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/reactivate: - patch: - operationId: reactivateSystem + post: + operationId: createSystemEntitlement tags: - - Backend - Systems - summary: /systems/{id}/reactivate - Reactivate suspended system - description: Reactivate a suspended system (requires manage:systems permission and hierarchical validation) + - Backend - Entitlements + summary: /systems/{id}/entitlements - Manually grant an add-on to a system + description: | + Manually grant an add-on to a system. Only the owner organization or a + Super Admin can manage grants directly (403 otherwise); the shop goes + through `POST /entitlements/activate` instead. + + The entitlement must exist in the catalog; `scope` is only accepted + for scoped catalog items, and a catalog item restricted to a + `system_type` cannot be granted to a system of a different known type. parameters: - name: id in: path @@ -8710,9 +10561,15 @@ paths: schema: type: string example: "sys_123456789" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSystemEntitlementRequest' responses: - '200': - description: System reactivated successfully + '201': + description: Entitlement created successfully content: application/json: schema: @@ -8720,29 +10577,54 @@ paths: properties: code: type: integer - example: 200 + example: 201 message: type: string - example: "system reactivated successfully" + example: "entitlement created successfully" data: - type: object - nullable: true + $ref: '#/components/schemas/SystemEntitlement' '400': - $ref: '#/components/responses/BadRequest' + description: Unknown entitlement, scope not supported by the catalog item, invalid source, or system-type mismatch ("this entitlement applies to nsec systems only") + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + description: Grant already exists for this (system, entitlement, scope) + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 409 + message: + type: string + example: "entitlement already exists for this system" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/regenerate-secret: - post: - operationId: regenerateSystemSecret + /systems/{id}/entitlements/{entitlement}: + put: + operationId: updateSystemEntitlement tags: - - Backend - Systems - summary: /systems/{id}/regenerate-secret - Regenerate system secret - description: Regenerate authentication secret for a specific system. Users can regenerate secrets for systems created by their organization based on hierarchical permissions. + - Backend - Entitlements + summary: /systems/{id}/entitlements/{entitlement} - Update a grant's expiry or revocation + description: | + Extend/reduce the expiry of a grant or toggle its revoked state. + Scoped grants are addressed with the `scope` query parameter. Only the + owner organization or a Super Admin can manage grants directly. parameters: - name: id in: path @@ -8751,9 +10633,28 @@ paths: schema: type: string example: "sys_123456789" + - name: entitlement + in: path + required: true + description: Catalog id of the granted add-on + schema: + type: string + example: "nsec-blacklist" + - name: scope + in: query + description: Application instance the grant is narrowed to (omit for system-wide grants) + schema: + type: string + example: "nethvoice1" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSystemEntitlementRequest' responses: '200': - description: System secret regenerated successfully + description: Entitlement updated successfully content: application/json: schema: @@ -8764,29 +10665,34 @@ paths: example: 200 message: type: string - example: "system secret regenerated successfully" + example: "entitlement updated successfully" data: - $ref: '#/components/schemas/System' + $ref: '#/components/schemas/SystemEntitlement' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - $ref: '#/components/responses/NotFound' + description: System not found, or no grant for this (entitlement, scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/reachability: - get: - operationId: checkSystemReachability + delete: + operationId: deleteSystemEntitlement tags: - - Backend - Systems - summary: /systems/{id}/reachability - Check if system web UI is reachable + - Backend - Entitlements + summary: /systems/{id}/entitlements/{entitlement} - Revoke a grant description: | - Checks if the system's web UI is reachable from the backend server. - The target URL is constructed from the system's FQDN and type: - - NS8: `https://{fqdn}/cluster-admin` - - NethSecurity: tries `https://{fqdn}:443` first, then `https://{fqdn}:9090` - - Returns `reachable: true` with the responding `url` on success, or `reachable: false` if unreachable. + Revoke a grant. The revocation is soft (sets `revoked_at`; the row is + kept for audit) and idempotent. Scoped grants are addressed with the + `scope` query parameter. Only the owner organization or a Super Admin + can manage grants directly. parameters: - name: id in: path @@ -8795,9 +10701,22 @@ paths: schema: type: string example: "sys_123456789" + - name: entitlement + in: path + required: true + description: Catalog id of the granted add-on + schema: + type: string + example: "nsec-blacklist" + - name: scope + in: query + description: Application instance the grant is narrowed to (omit for system-wide grants) + schema: + type: string + example: "nethvoice1" responses: '200': - description: Reachability check completed + description: Entitlement revoked successfully content: application/json: schema: @@ -8808,64 +10727,32 @@ paths: example: 200 message: type: string - example: "reachability check completed" + example: "entitlement revoked successfully" data: - type: object - properties: - reachable: - type: boolean - example: true - url: - type: string - description: The URL that responded successfully (empty if not reachable) - example: "https://firewall.example.com:9090" - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/SystemEntitlement' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - $ref: '#/components/responses/NotFound' + description: System not found, or no grant for this (entitlement, scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/register: - post: - operationId: registerSystem + /entitlements/catalog: + get: + operationId: listEntitlementCatalog tags: - - Backend - Systems - security: [] - summary: /systems/register - Register system with system_secret - description: | - Public endpoint for external systems to register themselves using their system_secret token. - This endpoint does NOT require authentication - the system_secret itself provides authentication. - - **Token Format:** `my_.` - - **Workflow:** - 1. User creates system in management UI → receives system_secret (one time only) - 2. External system uses system_secret to register → receives system_key - 3. External system uses system_key for future operations (inventory, heartbeat) - - **Important:** - - Systems can only be registered once - - Deleted systems cannot be registered - - The system_secret is validated using SHA256 hashing - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - system_secret - properties: - system_secret: - type: string - description: System secret token in format my_. - example: "my_a1b2c3d4e5f6g7h8i9j0.k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0" + - Backend - Entitlements + summary: /entitlements/catalog - List the add-on catalog + description: List all grantable add-on types. Available to any authenticated user with entitlements resource access. responses: '200': - description: System registered successfully + description: Entitlement catalog retrieved successfully content: application/json: schema: @@ -8876,94 +10763,38 @@ paths: example: 200 message: type: string - example: "system registered successfully" + example: "entitlement catalog retrieved successfully" data: type: object properties: - system_key: - type: string - description: System key to use for future operations - example: "my_sys_a1b2c3d4e5f6g7h8" - registered_at: - type: string - format: date-time - description: Timestamp when system was registered - example: "2025-11-06T10:30:00Z" - '400': - description: Invalid system secret format - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 400 - message: - type: string - example: "invalid system secret format" + catalog: + type: array + items: + $ref: '#/components/schemas/EntitlementCatalogItem' '401': - description: Invalid system secret (authentication failed) - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 401 - message: - type: string - example: "invalid system secret" + $ref: '#/components/responses/Unauthorized' '403': - description: System has been deleted - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 403 - message: - type: string - example: "system has been deleted" - '409': - description: System is already registered - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 409 - message: - type: string - example: "system is already registered" + $ref: '#/components/responses/Forbidden' '500': - $ref: '#/components/responses/InternalServerError' - - /systems/totals: - get: - operationId: getSystemsTotals - tags: - - Backend - Systems Stats - summary: /systems/totals - Get systems status summary - description: Get systems heartbeat status summary for dashboard (requires Admin or Support role) - parameters: - - name: timeout - in: query - description: Timeout in minutes for active/inactive determination - schema: - type: integer - minimum: 1 - maximum: 60 - default: 15 - example: 15 + $ref: '#/components/responses/InternalServerError' + + post: + operationId: createEntitlementCatalogItem + tags: + - Backend - Entitlements + summary: /entitlements/catalog - Create an add-on type + description: | + Add a new add-on type to the catalog. Only the owner organization or a + Super Admin can manage the catalog (403 otherwise). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEntitlementCatalogRequest' responses: - '200': - description: Systems status summary retrieved successfully + '201': + description: Catalog item created successfully content: application/json: schema: @@ -8971,29 +10802,24 @@ paths: properties: code: type: integer - example: 200 + example: 201 message: type: string - example: "system status retrieved" + example: "catalog item created successfully" data: - $ref: '#/components/schemas/SystemTotals' + $ref: '#/components/schemas/EntitlementCatalogItem' + '400': + description: Invalid id or legacy_alias (lowercase kebab-case required), invalid kind (service/module) or system_type (nsec/ns8/empty) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - - /systems/trend: - get: - operationId: getSystemsTrend - tags: - - Backend - Systems Stats - summary: /systems/trend - Get systems trend data - description: Get trend analysis of systems over time with cumulative counts - parameters: - - $ref: '#/components/parameters/TrendPeriodParam' - responses: - '200': - description: Systems trend data retrieved successfully + '409': + description: Catalog item already exists content: application/json: schema: @@ -9001,90 +10827,44 @@ paths: properties: code: type: integer - example: 200 + example: 409 message: type: string - example: "systems trend retrieved successfully" + example: "catalog item already exists" data: - $ref: '#/components/schemas/TrendResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' - /systems/export: - get: - operationId: exportSystems + /entitlements/catalog/{id}: + put: + operationId: updateEntitlementCatalogItem tags: - - Backend - Systems Stats - summary: /systems/export - Export systems to CSV or PDF + - Backend - Entitlements + summary: /entitlements/catalog/{id} - Update an add-on type description: | - Export systems with applied filters to CSV or PDF format. - - **Features:** - - Supports all filter parameters from GET /systems - - Maximum 10,000 systems per export (DoS protection) - - CSV format: Simple spreadsheet with all fields - - PDF format: Formatted report with metadata and filters applied - - File naming: `systems_export_YYYY-MM-DD_HHmmss.[csv|pdf]` - - **Permissions**: Requires `read:systems` permission and respects RBAC hierarchy - - **Export Fields:** - - Name, Type, Version, Status - - FQDN, IPv4 Address, IPv6 Address - - System Key, Notes - - Created At - - Organization Name, Organization Type - - Created By Name, Created By Email, Created By Organization + Update the display fields (display_name, description) of a catalog + item. The id and scoped flag are immutable. Only the owner + organization or a Super Admin can manage the catalog. parameters: - - name: format - in: query + - name: id + in: path required: true - description: Export format (csv or pdf) + description: Catalog id schema: type: string - enum: [csv, pdf] - example: csv - - $ref: '#/components/parameters/SearchParam' - - $ref: '#/components/parameters/SystemSortByParam' - - $ref: '#/components/parameters/SortDirectionParam' - - $ref: '#/components/parameters/SystemNameFilterParam' - - $ref: '#/components/parameters/SystemKeyFilterParam' - - $ref: '#/components/parameters/SystemTypeFilterParam' - - $ref: '#/components/parameters/SystemCreatedByFilterParam' - - $ref: '#/components/parameters/SystemVersionFilterParam' - - $ref: '#/components/parameters/SystemOrganizationFilterParam' - - $ref: '#/components/parameters/IncludeHierarchyParam' - - $ref: '#/components/parameters/SystemStatusFilterParam' + example: "nsec-blacklist" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateEntitlementCatalogRequest' responses: '200': - description: Export file generated successfully - content: - text/csv: - schema: - type: string - format: binary - description: CSV file with systems data - application/pdf: - schema: - type: string - format: binary - description: PDF report with systems data - headers: - Content-Disposition: - description: Attachment filename - schema: - type: string - example: 'attachment; filename="systems_export_2025-01-26_153045.csv"' - Content-Length: - description: File size in bytes - schema: - type: integer - '400': - description: Bad request (invalid format or too many systems) + description: Catalog item updated successfully content: application/json: schema: @@ -9092,60 +10872,43 @@ paths: properties: code: type: integer - example: 400 + example: 200 message: type: string - example: "too many systems to export (15000). maximum allowed: 10000. please apply more filters" + example: "catalog item updated successfully" data: - type: object - properties: - total_count: - type: integer - example: 15000 - max_limit: - type: integer - example: 10000 + $ref: '#/components/schemas/EntitlementCatalogItem' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory: - get: - operationId: getSystemInventory + delete: + operationId: deleteEntitlementCatalogItem tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory - Get system inventory history - description: Get paginated inventory history for a specific system. Supports date range filtering. + - Backend - Entitlements + summary: /entitlements/catalog/{id} - Delete an add-on type + description: | + Delete a catalog item. Refused while grants (including revoked ones, + kept for audit) still reference it. Only the owner organization or a + Super Admin can manage the catalog. parameters: - name: id in: path required: true - description: System ID - schema: - type: string - example: "sys_123456789" - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - name: from_date - in: query - required: false - description: Filter records from this date (RFC3339 format) - schema: - type: string - format: date-time - example: "2026-01-01T00:00:00Z" - - name: to_date - in: query - required: false - description: Filter records up to this date (RFC3339 format) + description: Catalog id schema: type: string - format: date-time - example: "2026-02-20T23:59:59Z" + example: "nsec-blacklist" responses: '200': - description: Inventory history retrieved successfully + description: Catalog item deleted successfully content: application/json: schema: @@ -9156,41 +10919,57 @@ paths: example: 200 message: type: string - example: "inventory history retrieved successfully" + example: "catalog item deleted successfully" data: type: object - properties: - records: - type: array - items: - $ref: '#/components/schemas/InventoryRecord' - pagination: - $ref: '#/components/schemas/Pagination' + nullable: true + example: null '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + description: Catalog item is referenced by existing grants + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 409 + message: + type: string + example: "catalog item is referenced by existing grants" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/latest: + /entitlements/catalog/{id}/availability: get: - operationId: getSystemInventoryLatest + operationId: listEntitlementAvailability tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/latest - Get latest system inventory - description: Get the most recent inventory data for a specific system. Returns the full InventoryRecord including the raw inventory JSON in the `data` field. + - Backend - Entitlements + summary: /entitlements/catalog/{id}/availability - List the commercial availability rules of an add-on + description: | + List the optional commercial restriction rules of a catalog item. + No rules at all means the add-on is available to everyone. parameters: - name: id in: path required: true - description: System ID + description: Catalog id schema: type: string - example: "sys_123456789" + example: "nsec-blacklist" responses: '200': - description: Latest inventory retrieved successfully. Returns null when no inventory is available (not an error condition). + description: Availability retrieved successfully content: application/json: schema: @@ -9201,44 +10980,50 @@ paths: example: 200 message: type: string - example: "latest inventory retrieved successfully" + example: "availability retrieved successfully" data: type: object - nullable: true - description: Latest inventory record, or null if no inventory exists - allOf: - - $ref: '#/components/schemas/InventoryRecord' + properties: + availability: + type: array + items: + $ref: '#/components/schemas/EntitlementAvailability' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/{inventory_id}: - get: - operationId: getSystemInventoryByID + post: + operationId: createEntitlementAvailability tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/{inventory_id} - Get a specific inventory record - description: Get a specific inventory record by its ID, scoped to the system. + - Backend - Entitlements + summary: /entitlements/catalog/{id}/availability - Add a commercial availability rule + description: | + Restrict who may buy/self-activate the add-on: unlock it for a whole + hierarchy role OR one specific organization (exactly one of the two). + By default (no rules) an add-on is available to everyone. Only the + owner organization or a Super Admin can manage availability rules. parameters: - name: id in: path required: true - description: System ID + description: Catalog id schema: type: string - example: "sys_123456789" - - name: inventory_id - in: path - required: true - description: Inventory record ID - schema: - type: integer - format: int64 - example: 42 + example: "nsec-blacklist" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEntitlementAvailabilityRequest' responses: - '200': - description: Inventory record retrieved successfully + '201': + description: Availability rule created successfully content: application/json: schema: @@ -9246,39 +11031,69 @@ paths: properties: code: type: integer - example: 200 + example: 201 message: type: string - example: "inventory record retrieved successfully" + example: "availability rule created successfully" data: - $ref: '#/components/schemas/InventoryRecord' + $ref: '#/components/schemas/EntitlementAvailability' '400': - $ref: '#/components/responses/BadRequest' + description: Both or neither of org_role/organization_id set, or invalid org_role (must be distributor, reseller or customer) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + description: Availability rule already exists + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 409 + message: + type: string + example: "availability rule already exists" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/changes: - get: - operationId: getSystemInventoryChanges + /entitlements/catalog/{id}/availability/{rule_id}: + delete: + operationId: deleteEntitlementAvailability tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/changes - Get inventory changes summary - description: Get an aggregated summary of all inventory changes for a specific system. Returns counts grouped by category and severity, not a paginated list. + - Backend - Entitlements + summary: /entitlements/catalog/{id}/availability/{rule_id} - Remove a commercial availability rule + description: Remove one availability rule. Only the owner organization or a Super Admin can manage availability rules. parameters: - name: id in: path required: true - description: System ID + description: Catalog id schema: type: string - example: "sys_123456789" + example: "nsec-blacklist" + - name: rule_id + in: path + required: true + description: Availability rule ID + schema: + type: string + example: "9f1b7c2e-4a3d-4a2b-8f6e-1c2d3e4f5a6b" responses: '200': - description: Changes summary retrieved successfully. Returns null when no inventory is available (not an error condition). + description: Availability rule removed successfully content: application/json: schema: @@ -9289,36 +11104,33 @@ paths: example: 200 message: type: string - example: "changes summary retrieved successfully" + example: "availability rule removed successfully" data: type: object nullable: true - description: Aggregated changes summary, or null if no inventory exists - allOf: - - $ref: '#/components/schemas/InventoryChangesSummary' + example: null '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/changes/latest: + /entitlements/available: get: - operationId: getSystemInventoryChangesLatest + operationId: listAvailableEntitlements tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/changes/latest - Get latest inventory changes - description: Get the changes summary scoped to the most recent inventory batch only. The `recent_changes` field equals `total_changes` since all changes are from the latest batch. - parameters: - - name: id - in: path - required: true - description: System ID - schema: - type: string - example: "sys_123456789" + - Backend - Entitlements + summary: /entitlements/available - List the add-ons the caller's organization may buy + description: | + The catalog items (services and modules) the CALLER's organization may + buy/self-activate on the shop, with availability rules applied. The + owner organization and Super Admins see the whole catalog. responses: '200': - description: Latest inventory changes summary retrieved successfully. Returns null when no inventory is available (not an error condition). + description: Available entitlements retrieved successfully content: application/json: schema: @@ -9329,110 +11141,79 @@ paths: example: 200 message: type: string - example: "latest inventory changes summary retrieved successfully" + example: "available entitlements retrieved successfully" data: type: object - nullable: true - description: Changes summary for the latest inventory batch, or null if no inventory exists - allOf: - - $ref: '#/components/schemas/InventoryChangesSummary' + properties: + available: + type: array + items: + $ref: '#/components/schemas/EntitlementCatalogItem' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/diffs: + /entitlements/grants: get: - operationId: getSystemInventoryDiffs + operationId: getEntitlementGrants tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/diffs - Get inventory diffs - description: Get paginated inventory diffs for a specific system. Supports filtering by severity, category, diff type, date range, and text search on field path and values. + - Backend - Entitlements + summary: /entitlements/grants - Grants report with filters + description: | + Paginated grants report. The owner organization and Super Admins see + the whole fleet; other users only the grants of systems in their + hierarchy. parameters: - - name: id - in: path - required: true - description: System ID - schema: - type: string - example: "sys_123456789" - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - name: search + - name: entitlement in: query - required: false - description: "Case-insensitive text search across field_path, previous_value, and current_value." + description: Filter by catalog id schema: type: string - minLength: 1 - example: "network" - - name: severity - in: query - required: false - description: Filter by severity level. Supports multiple values. - schema: - type: array - items: - type: string - enum: [low, medium, high, critical] - example: ["high", "critical"] - style: form - explode: true - - name: category + example: "nsec-blacklist" + - name: organization_id in: query - required: false - description: Filter by change category. Supports multiple values. + description: Filter by the organization owning the system schema: - type: array - items: - type: string - enum: [os, hardware, network, security, backup, features, modules, cluster, nodes, system] - example: ["os", "network"] - style: form - explode: true - - name: diff_type + type: string + example: "akkbs6x2wo82" + - name: source in: query - required: false - description: Filter by diff type. Supports multiple values. + description: Filter by grant source schema: - type: array - items: - type: string - enum: [create, update, delete] - example: ["create", "update"] - style: form - explode: true - - name: inventory_id + type: string + enum: [manual, shop, legacy-import] + - name: active in: query - required: false - description: Filter by specific inventory record IDs. Supports multiple values. Use the `inventory_ids` from a `/timeline` group to fetch diffs for that date group. + description: When "true", only active grants (not revoked, not expired) schema: - type: array - items: - type: integer - format: int64 - example: [42, 43] - style: form - explode: true - - name: from_date + type: string + enum: ["true"] + - name: expiring_before in: query - required: false - description: Filter diffs from this date (RFC3339 format) + description: Only grants expiring before this RFC3339 timestamp schema: type: string format: date-time - example: "2026-01-01T00:00:00Z" - - name: to_date + example: "2026-12-31T23:59:59Z" + - name: page in: query - required: false - description: Filter diffs up to this date (RFC3339 format) + description: Page number schema: - type: string - format: date-time - example: "2026-02-20T23:59:59Z" + type: integer + default: 1 + - name: page_size + in: query + description: Page size (max 200) + schema: + type: integer + default: 50 + maximum: 200 responses: '200': - description: Inventory diffs retrieved successfully + description: Grants retrieved successfully content: application/json: schema: @@ -9443,41 +11224,49 @@ paths: example: 200 message: type: string - example: "inventory diffs retrieved successfully" + example: "grants retrieved successfully" data: type: object properties: - diffs: + grants: type: array items: - $ref: '#/components/schemas/InventoryDiff' - pagination: - $ref: '#/components/schemas/Pagination' + $ref: '#/components/schemas/EntitlementGrantReportRow' + total: + type: integer + example: 42 + page: + type: integer + example: 1 + page_size: + type: integer + example: 50 + '400': + description: expiring_before is not a valid RFC3339 timestamp + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/diffs/latest: + /entitlements/stats: get: - operationId: getSystemInventoryDiffsLatest + operationId: getEntitlementStats tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/diffs/latest - Get latest inventory diffs batch - description: Get all diffs from the most recent inventory snapshot. Returns the complete batch (not paginated), ordered by severity (critical first) then by creation time descending. - parameters: - - name: id - in: path - required: true - description: System ID - schema: - type: string - example: "sys_123456789" + - Backend - Entitlements + summary: /entitlements/stats - Active grants per entitlement per organization + description: | + Active grants aggregated per entitlement per organization. Same + visibility scoping as the grants report: owner organization and Super + Admins see the whole fleet, other users only their hierarchy. responses: '200': - description: Latest inventory diffs retrieved successfully + description: Entitlement stats retrieved successfully content: application/json: schema: @@ -9488,119 +11277,81 @@ paths: example: 200 message: type: string - example: "latest inventory diffs retrieved successfully" + example: "entitlement stats retrieved successfully" data: type: object - description: Latest diffs batch data properties: - diffs: + stats: type: array items: - $ref: '#/components/schemas/InventoryDiff' - count: - type: integer - description: Number of diffs in this batch - example: 5 - current_inventory_id: - type: integer - format: int64 - description: ID of the current inventory record these diffs belong to. Only present when there are diffs. - example: 42 + $ref: '#/components/schemas/EntitlementStatsRow' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /systems/{id}/inventory/timeline: + /entitlements/report: get: - operationId: getSystemInventoryTimeline + operationId: getEntitlementReport tags: - - Backend - Systems Inventory - summary: /systems/{id}/inventory/timeline - Get inventory timeline grouped by date + - Backend - Entitlements + summary: /entitlements/report - Fleet-wide add-on analytics (owner/Super Admin) description: | - Get a date-grouped inventory timeline for a specific system. - Returns a filtered summary (severity counters) and groups of inventory dates. - Each group includes `inventory_ids` — the IDs of inventory records collected that day. - Groups with no matching diffs still appear with `change_count: 0`, allowing the UI - to display "X inventories, no changes" badges. Pagination is over date groups. - To fetch the actual diffs for a group, call `GET /systems/{id}/inventory/diffs?inventory_id=42&inventory_id=43` - with the `inventory_ids` from the group. - Supports text search on diff field paths and values. - parameters: - - name: id - in: path - required: true - description: System ID - schema: - type: string - example: "sys_123456789" - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/PageSizeParam' - - name: search - in: query - required: false - description: "Case-insensitive text search across diff field_path, previous_value, and current_value. Affects change_count in groups and summary counters." - schema: - type: string - minLength: 1 - example: "dhcp" - - name: severity - in: query - required: false - description: Filter by severity level. Supports multiple values. - schema: - type: array - items: - type: string - enum: [low, medium, high, critical] - example: ["high", "critical"] - style: form - explode: true - - name: category - in: query - required: false - description: Filter by change category. Supports multiple values. - schema: - type: array - items: - type: string - enum: [os, hardware, network, security, backup, features, modules, cluster, nodes, system] - example: ["os", "security"] - style: form - explode: true - - name: diff_type + The commercial overview of every add-on grant: lifecycle totals + (active/expired/revoked/pending/suspended, perpetual, expiring in + 30/60/90 days), the per-type breakdown, the renewal distribution and + a 12-month activation trend. The per-organization and per-tier + breakdowns live on their own paginated endpoints. Owner organization + / Super Admin only; deleted systems are excluded. + responses: + '200': + description: Entitlement report retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement report retrieved successfully" + data: + $ref: '#/components/schemas/EntitlementReport' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/report/organizations: + get: + operationId: getEntitlementReportOrganizations + tags: + - Backend - Entitlements + summary: /entitlements/report/organizations - Per-organization slice of the add-on report + description: | + Grants grouped by the organization owning the systems, most active + first, paginated and searchable by organization name. Owner + organization / Super Admin only. + parameters: + - name: search in: query - required: false - description: Filter by diff type. Supports multiple values. - schema: - type: array - items: - type: string - enum: [create, update, delete] - example: ["create", "update"] - style: form - explode: true - - name: from_date + schema: { type: string } + description: Case-insensitive filter on the organization name + - name: page in: query - required: false - description: Show dates from this timestamp (RFC3339 format) - schema: - type: string - format: date-time - example: "2026-01-01T00:00:00Z" - - name: to_date + schema: { type: integer, default: 1 } + - name: page_size in: query - required: false - description: Show dates up to this timestamp (RFC3339 format) - schema: - type: string - format: date-time - example: "2026-03-17T23:59:59Z" + schema: { type: integer, default: 10, maximum: 200 } responses: '200': - description: Inventory timeline retrieved successfully + description: Report organizations retrieved successfully content: application/json: schema: @@ -9611,162 +11362,175 @@ paths: example: 200 message: type: string - example: "inventory timeline retrieved successfully" + example: "report organizations retrieved successfully" data: type: object properties: - summary: - $ref: '#/components/schemas/InventoryTimelineSummary' - groups: + organizations: type: array items: - $ref: '#/components/schemas/InventoryTimelineGroup' - pagination: - $ref: '#/components/schemas/Pagination' + type: object + properties: + organization_id: { type: string, example: "akkbs6x2wo82" } + organization_name: { type: string, example: "ACME S.r.l." } + org_type: { type: string, example: "reseller", description: "distributor | reseller | customer | owner" } + systems: { type: integer, example: 12 } + active: { type: integer, example: 18 } + total: { type: integer, example: 20 } + total: { type: integer, example: 130 } + page: { type: integer, example: 1 } + page_size: { type: integer, example: 10 } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /alerts: + /entitlements/report/tiers: get: - operationId: getAlerts + operationId: getEntitlementReportTiers tags: - - Backend - Alerts - summary: "/alerts - List active alerts (cross-hierarchy)" + - Backend - Entitlements + summary: /entitlements/report/tiers - Per-tier slice of the add-on report description: | - Retrieves active alerts from Mimir for the caller's scope, paginated. Each - alert is enriched with a `system` object (`name`, `type`) looked up from the - local `systems` table, so the UI can render the system column without an - extra round-trip per row. - - Sortable by `starts_at` (default desc), `severity` (criticality rank: critical - > warning > info), `alertname`, or `status` (Alertmanager state). `fingerprint` - is used as a stable tiebreaker so pagination doesn't shift between requests. - - Scope follows the same three modes as `/alerts/totals`: - - `organization_id` omitted → caller's full hierarchy (cross-tenant fan-out). - - `organization_id=X` → single tenant `X`. - - `organization_id=X&include=descendants` → `X` plus its sub-tree. - - All filter params support **multiple values** (repeat the param): values within - the same filter are matched as OR; different filters AND together. Example: - `?severity=critical&severity=warning&alertname=CVE-2024-1234` returns - CVE-2024-1234 alerts that are critical or warning. - - Per-tenant failures during fan-out (timeout, 5xx) are non-fatal: the rest of the - result is returned and the failure is reported in the `warnings` array. - security: - - BearerAuth: [] + Grants grouped by shop tier (variable products only), paginated and + searchable by add-on id or tier label. Owner organization / Super + Admin only. parameters: - - name: organization_id - in: query - description: | - Target organization ID(s). Repeat the param to pass multiple values. - Optional for all roles except Customer (where it is ignored). - schema: - type: array - items: - type: string - style: form - explode: true - - name: include + - name: search in: query - description: Set to `descendants` together with `organization_id` to expand each value to its sub-tree. - schema: - type: string - enum: [descendants] + schema: { type: string } + description: Case-insensitive filter on the add-on id or tier label - name: page in: query - description: 1-based page number. - schema: - type: integer - minimum: 1 - default: 1 + schema: { type: integer, default: 1 } - name: page_size in: query - description: Page size. Default 50, max 100. - schema: - type: integer - minimum: 1 - maximum: 100 - default: 50 - - name: sort_by - in: query - description: | - Sort column (allowlist). `assigned_user_name` sorts by the current - assignee's display name (case-insensitive; unassigned alerts group - together at one end) — active list only, history falls back to its - default. - schema: - type: string - enum: [starts_at, severity, alertname, status, assigned_user_name] - default: starts_at - - name: sort_direction - in: query - schema: - type: string - enum: [asc, desc] - default: desc - - name: status - in: query - description: Filter alerts by Alertmanager state. Supports multiple values. - schema: - type: array - items: - type: string - enum: [active, suppressed, unprocessed] - style: form - explode: true - - name: severity - in: query - description: Filter alerts by severity label. Supports multiple values. - schema: - type: array - items: - type: string - enum: [critical, warning, info] - style: form - explode: true - - name: system_key - in: query - description: Filter alerts by system_key label. Supports multiple values. - schema: - type: array - items: - type: string - style: form - explode: true - - name: alertname - in: query - description: | - Filter alerts by alertname label (the alert "type" — e.g. `HighCPU`, - `DiskFull`, `CVE-2024-1234`). Supports multiple values. - schema: - type: array - items: - type: string - style: form - explode: true - - name: assigned_user_id - in: query - description: | - Filter alerts by current assignee (logto_id of the user, as - returned in `assigned_to.user_id`). Supports multiple values. The - literal value `none` matches unassigned alerts, so - `assigned_user_id=&assigned_user_id=none` means "mine or up - for grabs". - schema: - type: array - items: - type: string - style: form - explode: true + schema: { type: integer, default: 10, maximum: 200 } + responses: + '200': + description: Report tiers retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "report tiers retrieved successfully" + data: + type: object + properties: + tiers: + type: array + items: + type: object + properties: + entitlement: { type: string, example: "nsec-blacklist" } + label: { type: string, example: "16 - 30 device" } + count: { type: integer, example: 14 } + total: { type: integer, example: 12 } + page: { type: integer, example: 1 } + page_size: { type: integer, example: 10 } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/activate: + post: + operationId: activateEntitlement + tags: + - Backend - Entitlements + summary: /entitlements/activate - Shop-facing activation/renewal of an add-on + description: | + Activate or renew an add-on after a shop purchase or subscription + renewal (called by the NethShop webhook). The system is addressed by + its key; the entitlement accepts the canonical catalog id or the + legacy alias. + + Idempotent upsert: an existing (system, entitlement, scope) grant is + renewed in place (new expiry, revocation cleared), so webhook retries + are safe. The grant is recorded with `source: shop`. + + Requires the `manage:entitlements` permission — held by the + Backoffice, Admin and Super Admin user roles, or an owner API key. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEntitlementRequest' + responses: + '200': + description: Entitlement activated successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement activated successfully" + data: + $ref: '#/components/schemas/SystemEntitlement' + '400': + description: Unknown entitlement, scope not supported by the catalog item, or system-type mismatch ("this entitlement applies to nsec systems only") + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: System not found for this key + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/pending: + post: + operationId: pendingEntitlement + tags: + - Backend - Entitlements + summary: /entitlements/pending - Shop-facing "order placed, payment not confirmed" + description: | + Mark an add-on activation as pending: the shop order exists but the + payment (bank transfer/RiBa) has not been confirmed yet. Display-only + — the UI shows the entitlement as `pending` instead of offering + another purchase; enforcement is NOT affected (collect keeps + answering 403 until the real activation). + + On a fresh purchase a non-active stub grant is created; on an + existing grant (renewal, re-buy after revocation/expiry) only the + pending marker is stamped. Idempotent. The marker is cleared by + `POST /entitlements/activate` (order completed) or by + `POST /entitlements/deactivate` with the same `source_ref` (order + cancelled before payment). Same addressing and auth as activate + (`manage:entitlements` permission). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PendingEntitlementRequest' responses: '200': - description: Paginated list of active alerts + description: Entitlement activation marked pending content: application/json: schema: @@ -9777,95 +11541,83 @@ paths: example: 200 message: type: string - example: alerts retrieved successfully + example: "entitlement activation marked pending" data: - type: object - properties: - alerts: - type: array - items: - $ref: '#/components/schemas/ActiveAlert' - pagination: - $ref: '#/components/schemas/Pagination' - warnings: - type: array - description: | - Per-tenant errors encountered during fan-out. Always present - (empty array when every tenant responded OK). Each entry is - a string `org : `. - items: - type: string - examples: - ActiveAlertExample: - summary: One active warning across the caller's hierarchy - description: | - A single warning alert. System identity (id, key, name, type) is - carried as labels, stamped at ingest time. `state="active"` means - Mimir has not been told to silence it; an actively-muted alert - would have `state="suppressed"` and a non-empty `silencedBy`. - value: - code: 200 - message: alerts retrieved successfully - data: - alerts: - - fingerprint: "0a9d04bb6eed523f" - labels: - alertname: "DiskFilling" - severity: "warning" - system_id: "e4eb4844-46f6-448c-8279-7cfedf5e1037" - system_key: "NETH-D417-A2C2-7810-43D2-984B-2164-34C1-B22E" - system_name: "test-sys" - system_type: "ns8" - annotations: - summary: "/var is 92% full on test-sys" - description: "Disk usage exceeded the warning threshold." - status: - state: "active" - silencedBy: [] - inhibitedBy: [] - startsAt: "2026-05-12T08:14:00Z" - endsAt: "2026-05-12T08:44:00Z" - assigned_to: - user_id: "c5gpnoo2do48" - user_name: "R1C1 Admin" - user_org_id: "m4m3mdjdiizs" - user_org_name: "R1C1 Customer" - assigned_at: "2026-05-12T08:20:00Z" - pagination: - page: 1 - page_size: 50 - total_count: 1 - total_pages: 1 - has_next: false - has_prev: false - warnings: [] - PartialFanoutWarning: - summary: One tenant timed out during fan-out - description: | - When `organization_id` is omitted (or `include=descendants` is - used), the request fans out to every tenant in scope. A single - slow Mimir does not fail the whole request — the rest of the - results are returned and the failing tenant lands in `warnings`. - value: - code: 200 - message: alerts retrieved successfully - data: - alerts: [] - pagination: - page: 1 - page_size: 50 - total_count: 0 - total_pages: 0 - has_next: false - has_prev: false - warnings: - - "org pt8gqs6y5wpr: context deadline exceeded" + $ref: '#/components/schemas/SystemEntitlement' '400': - $ref: '#/components/responses/BadRequest' + description: Unknown entitlement, scope not supported by the catalog item, or system-type mismatch + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: System not found for this key + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/deactivate: + post: + operationId: deactivateEntitlement + tags: + - Backend - Entitlements + summary: /entitlements/deactivate - Shop-facing revocation of an add-on + description: | + Revoke a shop-managed grant when the subscription is cancelled or + expires (called by the NethShop webhook). Same addressing and auth as + `POST /entitlements/activate` (`manage:entitlements` permission). + The revocation is soft and idempotent. + + When `source_ref` is provided it is matched against the grant: a + PENDING activation with the same ref is cleared instead of revoked + (order cancelled before payment), and a grant whose `source_ref` + differs is left untouched — cancelling an old order can never revoke + an entitlement another order paid for. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeactivateEntitlementRequest' + responses: + '200': + description: Entitlement deactivated successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement deactivated successfully" + data: + $ref: '#/components/schemas/SystemEntitlement' + '400': + description: Unknown entitlement + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: System not found for this key, or no grant for this (entitlement, scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '500': $ref: '#/components/responses/InternalServerError' @@ -14658,3 +16410,169 @@ paths: $ref: '#/components/responses/InternalServerError' '503': description: Webhook authentication not configured + + # =========================================== + # FEED AUTHORIZATION (Collect - /auth forwardAuth) + # =========================================== + + /auth: + get: + operationId: authCheck + servers: + - url: https://collect.your-domain.com/api + description: Collect API server (port 8081) + tags: + - Collect - Feed Authorization + summary: /auth - Subscription-level authorization check + description: | + Native replacement of the legacy my /auth used by the appliance + enterprise feeds (distfeed, ns-signatures-proxy, blacklists) via + forwardAuth. Returns 200 when the system credentials belong to a valid + subscription; unknown, unregistered, suspended and deleted systems get + 401 (same semantics as the legacy nethserver_basic_auth.php). + security: + - BasicAuth: [system_key:system_secret] + responses: + '200': + description: Valid subscription + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "authorized" + data: + type: object + nullable: true + example: null + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + + /auth/service/{id}: + get: + operationId: authCheckService + servers: + - url: https://collect.your-domain.com/api + description: Collect API server (port 8081) + tags: + - Collect - Feed Authorization + summary: /auth/service/{id} - Per-add-on entitlement check + description: | + Returns 200 only when the system holds an active grant (not revoked, + not expired; `valid_until` null = perpetual) for the requested service + id, 403 otherwise. The id may be the canonical catalog id or a legacy + wire alias (the feeds still call `ng-blacklist` while the canonical id + is `nsec-blacklist`). + + `?scope=` also matches grants narrowed to that + application instance (e.g. `nethvoice1`); a system-wide grant (empty + scope) covers every instance. Without `scope` only system-wide grants + count. + security: + - BasicAuth: [system_key:system_secret] + parameters: + - name: id + in: path + required: true + description: Service id (canonical catalog id or legacy alias) + schema: + type: string + example: "ng-blacklist" + - name: scope + in: query + description: Application instance to check an instance-narrowed grant for + schema: + type: string + example: "nethvoice1" + responses: + '200': + description: The system holds an active grant for the service + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "authorized" + data: + type: object + nullable: true + example: null + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Valid subscription but the service is not enabled for this system + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 403 + message: + type: string + example: "service not enabled for this system" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' + + /auth/product/{name}: + get: + operationId: authCheckProduct + servers: + - url: https://collect.your-domain.com/api + description: Collect API server (port 8081) + tags: + - Collect - Feed Authorization + summary: /auth/product/{name} - Product-level authorization check + description: | + Pass-through for systems with a valid subscription: product-level + enforcement is not applied yet, so any authenticated system gets 200 + (mirrors the transitional broker behaviour for /auth/product/*). + security: + - BasicAuth: [system_key:system_secret] + parameters: + - name: name + in: path + required: true + description: Product name + schema: + type: string + example: "nethservice" + responses: + '200': + description: Valid subscription + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "authorized" + data: + type: object + nullable: true + example: null + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' diff --git a/collect/README.md b/collect/README.md index f97c9942b..fb754f534 100644 --- a/collect/README.md +++ b/collect/README.md @@ -402,3 +402,13 @@ Result after templating: - Invalid template syntax is logged as a warning; the annotation remains unchanged - Non-string annotation values are preserved as-is - Static annotations (without template syntax) pass through unchanged + +## Feed authorization (`/auth`) + +The NethSecurity enterprise feeds (distfeed, ns-signatures-proxy, blacklists) forward-auth the appliance's `system_key:system_secret` Basic credentials against `/api/auth`. The endpoints replace the legacy my `/auth` with the same wire semantics: + +- `GET /api/auth` — `200` when the credentials belong to a registered, non-suspended system (valid subscription); `401` otherwise +- `GET /api/auth/service/{id}` — `200` when the system holds an **active entitlement** for `{id}` (from `system_entitlements`: not revoked, not expired); `403` otherwise. The id may be the canonical catalog id (e.g. `nsec-blacklist`) or the legacy wire alias the feeds still call (e.g. `ng-blacklist`), resolved through `entitlement_catalog.legacy_alias`. The optional `?scope=` query (e.g. `?scope=nethvoice1`) also matches grants narrowed to that instance; a system-wide grant covers every instance +- `GET /api/auth/product/{name}` — `200` for any valid system (product-level enforcement is deferred) + +Entitlements are managed by the backend (`/api/systems/:id/entitlements`, `/api/entitlements/*`) and activated by the NethShop purchase flow; collect only reads them, with no extra caching layer, so grants and revocations take effect on the next feed check. diff --git a/collect/main.go b/collect/main.go index 9e52671a0..a20ecbd8e 100644 --- a/collect/main.go +++ b/collect/main.go @@ -168,6 +168,20 @@ func main() { systemsGroup.DELETE("/backups/:id", methods.DeleteBackup) } + // =========================================== + // FEED AUTHORIZATION (/auth forwardAuth) + // =========================================== + // Native replacement of the legacy my /auth used by the nsec enterprise + // feeds (distfeed, ns-signatures-proxy, blacklists) via forwardAuth. + // Subscription check comes from BasicAuthMiddleware; per-add-on grants + // come from system_entitlements (403 without an active grant). + authGroup := api.Group("/auth", middleware.BasicAuthMiddleware()) + { + authGroup.GET("", methods.AuthCheck) + authGroup.GET("/service/:id", methods.AuthCheckService) + authGroup.GET("/product/:name", methods.AuthCheckProduct) + } + // =========================================== // EXTERNAL SERVICES PROXY // =========================================== diff --git a/collect/methods/auth.go b/collect/methods/auth.go new file mode 100644 index 000000000..940658a03 --- /dev/null +++ b/collect/methods/auth.go @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * author: Edoardo Spadoni + */ + +package methods + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/nethesis/my/collect/database" + "github.com/nethesis/my/collect/logger" + "github.com/nethesis/my/collect/response" +) + +// Native /auth for the appliance enterprise feeds (distfeed, ns-signatures- +// proxy, blacklists). The feeds forwardAuth system_key:secret Basic +// credentials; BasicAuthMiddleware already rejects unknown, unregistered, +// suspended and deleted systems, so reaching a handler means the +// subscription is valid. +// +// GET /auth -> 200 (valid subscription) +// GET /auth/service/[?scope=] -> 200 if the system holds an active +// entitlement for , 403 otherwise. +// ?scope= checks a grant +// narrowed to that application instance +// (e.g. nethvoice1); a system-wide +// grant (empty scope) also covers every +// instance (fallback). +// GET /auth/product/ -> 200 (valid subscription) — product- +// level enforcement is deferred until +// the legacy system_products data is +// mapped; this mirrors the transitional +// broker behaviour +// +// Same semantics as the legacy nethserver_basic_auth.php: 401 = bad or +// suspended credentials, 403 = valid system without the specific add-on. + +// AuthCheck handles GET /auth — subscription-level check only. +func AuthCheck(c *gin.Context) { + if _, ok := getAuthenticatedSystemID(c); !ok { + c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil)) + return + } + c.JSON(http.StatusOK, response.OK("authorized", nil)) +} + +// AuthCheckService handles GET /auth/service/:id — grants access only when +// the system holds an active entitlement for the requested service id +// (active = not revoked, not expired; valid_until NULL = perpetual). +func AuthCheckService(c *gin.Context) { + systemID, ok := getAuthenticatedSystemID(c) + if !ok { + c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil)) + return + } + + serviceID := c.Param("id") + if serviceID == "" { + c.JSON(http.StatusForbidden, response.Forbidden("service id required", nil)) + return + } + + // A system-wide grant (scope '') always qualifies; when the caller asks + // for a specific application instance, an instance-narrowed grant does + // too. Without ?scope only system-wide grants count. + // The requested id may be a legacy wire alias (the feeds still call + // ng-blacklist while the canonical catalog id is nsec-blacklist): the + // join resolves both. + scope := c.Query("scope") + + var active bool + err := database.DB.QueryRow( + `SELECT EXISTS ( + SELECT 1 FROM system_entitlements e + JOIN entitlement_catalog cat ON cat.id = e.entitlement + WHERE e.system_id = $1 + AND ($2 IN (cat.id, cat.legacy_alias)) + AND e.scope IN ('', $3) + AND e.revoked_at IS NULL + AND (e.valid_until IS NULL OR e.valid_until > NOW()) + )`, systemID, serviceID, scope).Scan(&active) + if err != nil { + logger.ComponentLogger("auth").Error().Err(err). + Str("system_id", systemID). + Str("service", serviceID). + Msg("Entitlement lookup failed") + c.JSON(http.StatusInternalServerError, response.InternalServerError("entitlement lookup failed", nil)) + return + } + + if !active { + c.JSON(http.StatusForbidden, response.Forbidden("service not enabled for this system", nil)) + return + } + + c.JSON(http.StatusOK, response.OK("authorized", nil)) +} + +// AuthCheckProduct handles GET /auth/product/:name — product-level checks are +// not enforced yet (fase 1): any system with a valid subscription passes, +// matching the transitional broker's behaviour for /auth/product/*. +func AuthCheckProduct(c *gin.Context) { + if _, ok := getAuthenticatedSystemID(c); !ok { + c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil)) + return + } + c.JSON(http.StatusOK, response.OK("authorized", nil)) +} diff --git a/docs/docs/features/entitlements.md b/docs/docs/features/entitlements.md new file mode 100644 index 000000000..5d078ef4a --- /dev/null +++ b/docs/docs/features/entitlements.md @@ -0,0 +1,55 @@ +--- +sidebar_position: 8 +--- + +# Entitlements + +Granular add-on licensing for systems: firewall services and per-application modules, purchased on NethShop and enforced in real time. + +:::info ALPHA +The entitlements interface is currently in alpha. The purchase flow on NethShop is being rolled out and some screens may change in future releases. +::: + +## Overview + +An **entitlement** is a license that grants one add-on to one system. Two kinds exist: + +- **Service** — a NethSecurity firewall add-on, granted system-wide (e.g. *Advanced Threat Shield*, *High Availability*, *Sandbox*) +- **Module** — an add-on for a **single application instance** of a NethServer 8 cluster (e.g. the *Chat* module for `nethvoice1`, but not for `nethvoice2`) + +Entitlements are bought on **NethShop** and appear on the system automatically once the subscription is active. Renewals extend the expiry in place; cancelling the subscription revokes the grant. The appliance features validate their license in real time against My Nethesis (`/auth`): without an active entitlement the feature is not served. + +## The Entitlements tab + +Every system whose type is known (NethSecurity or NethServer 8) shows an **Entitlements** tab: + +- On a **firewall** the table lists the available services: purchased ones show the payment reference, validity and next renewal; the others offer **Buy on NethShop**. +- On a **cluster** the table has two levels: the application instances found on the system (from the inventory) and, under each instance, the modules available for that application — purchased or buyable per instance. + +The **Buy on NethShop** button opens the shop with the system (and application instance) pre-selected, so the purchase is bound to the right target with no manual input. + +## Roles and permissions + +| Capability | Who | +|---|---| +| See entitlements and expirations (`read:entitlements`) | All user roles, within their hierarchy | +| Buy on NethShop / cancel a subscription (`manage:entitlements`) | Admin, Backoffice, Super Admin | +| Manage the catalog, manual grants, fleet-wide view | Owner organization or Super Admin (Nethesis) | + +Distributors and resellers cannot self-activate add-ons: everything flows through the shop. + +## Entitlements catalog (Nethesis) + +Owner and Super Admin users have an **Entitlements** entry in the side menu with the catalog of add-on types. Creating a type takes a kind (Service or Module), the target application for modules (the id is composed automatically, e.g. `nethvoice` + `chat` → `nethvoice-chat`), a display name and a description. A newly created type is **immediately purchasable by everyone**; optional availability rules can restrict a type to specific hierarchy roles or organizations. + +Deleting a type is refused while grants reference it. + +## Reporting + +`GET /api/entitlements/grants` (with filters by entitlement, organization, source, active state and expiry window) and `GET /api/entitlements/stats` provide the licensing report: buyers see their own hierarchy — every module with its expiry and renewal — while owner and Super Admin see the whole fleet. + +## For developers + +- Grants live in `system_entitlements` (one row per system + entitlement + scope; renewals update `valid_until` in place, revocations keep the row for audit). +- Enforcement is served by collect: `GET /auth/service/[?scope=]` with the system's Basic credentials returns `200` with an active grant, `403` without. Legacy wire ids (`ng-*`) are resolved through the catalog `legacy_alias`, so the appliance feeds keep calling the historical paths unchanged. +- The shop activates and renews grants through `POST /api/entitlements/activate` (idempotent, addressed by `system_key`) and revokes them with `POST /api/entitlements/deactivate`. diff --git a/docs/i18n/it/docusaurus-plugin-content-docs/current/features/entitlements.md b/docs/i18n/it/docusaurus-plugin-content-docs/current/features/entitlements.md new file mode 100644 index 000000000..34315a645 --- /dev/null +++ b/docs/i18n/it/docusaurus-plugin-content-docs/current/features/entitlements.md @@ -0,0 +1,55 @@ +--- +sidebar_position: 8 +--- + +# Entitlements + +Licenze granulari per i sistemi: servizi firewall e moduli per applicazione, acquistati su NethShop e verificati in tempo reale. + +:::info ALPHA +L'interfaccia degli entitlements è attualmente in alpha. Il flusso di acquisto su NethShop è in fase di rilascio e alcune schermate potranno cambiare. +::: + +## Panoramica + +Un **entitlement** è una licenza che abilita un add-on su un sistema. Esistono due tipi: + +- **Service** — un add-on del firewall NethSecurity, valido per l'intero sistema (es. *Advanced Threat Shield*, *High Availability*, *Sandbox*) +- **Module** — un add-on per una **singola istanza applicazione** di un cluster NethServer 8 (es. il modulo *Chat* per `nethvoice1`, ma non per `nethvoice2`) + +Gli entitlements si acquistano su **NethShop** e compaiono sul sistema automaticamente all'attivazione della subscription. Il rinnovo estende la scadenza; l'annullamento della subscription revoca la licenza. Le funzionalità dell'appliance validano la licenza in tempo reale su My Nethesis (`/auth`): senza un entitlement attivo la funzionalità non viene erogata. + +## La tab Entitlements + +Ogni sistema di tipo noto (NethSecurity o NethServer 8) mostra una tab **Entitlements**: + +- Su un **firewall** la tabella elenca i servizi disponibili: quelli acquistati mostrano riferimento di pagamento, validità e prossimo rinnovo; gli altri offrono **Buy on NethShop**. +- Su un **cluster** la tabella è a due livelli: le istanze applicazione presenti sul sistema (dall'inventory) e, sotto ognuna, i moduli disponibili per quell'applicazione — acquistati o acquistabili per istanza. + +Il pulsante **Buy on NethShop** apre lo shop con sistema (e istanza applicazione) già preselezionati: l'acquisto è legato al bersaglio giusto senza input manuale. + +## Ruoli e permessi + +| Capacità | Chi | +|---|---| +| Vedere entitlements e scadenze (`read:entitlements`) | Tutti i ruoli utente, nella propria gerarchia | +| Acquistare su NethShop / annullare una subscription (`manage:entitlements`) | Admin, Backoffice, Super Admin | +| Gestire il catalogo, grant manuali, vista sull'intera flotta | Organizzazione owner o Super Admin (Nethesis) | + +Distributori e reseller non possono auto-attivarsi add-on: tutto passa dallo shop. + +## Catalogo entitlements (Nethesis) + +Gli utenti owner e Super Admin hanno la voce **Entitlements** nel menu laterale con il catalogo dei tipi di add-on. La creazione richiede il kind (Service o Module), l'applicazione di destinazione per i moduli (l'id si compone automaticamente, es. `nethvoice` + `chat` → `nethvoice-chat`), nome e descrizione. Un tipo appena creato è **immediatamente acquistabile da tutti**; regole di disponibilità opzionali possono riservarlo a ruoli o organizzazioni specifiche. + +La cancellazione di un tipo è rifiutata finché esistono licenze che lo referenziano. + +## Reportistica + +`GET /api/entitlements/grants` (con filtri per entitlement, organizzazione, origine, stato e finestra di scadenza) e `GET /api/entitlements/stats` forniscono il report licenze: chi acquista vede la propria gerarchia — ogni modulo con scadenza e rinnovo — mentre owner e Super Admin vedono l'intera flotta. + +## Per gli sviluppatori + +- Le licenze vivono in `system_entitlements` (una riga per sistema + entitlement + scope; i rinnovi aggiornano `valid_until` in place, le revoche conservano la riga per audit). +- L'enforcement è servito da collect: `GET /auth/service/[?scope=]` con le credenziali Basic del sistema risponde `200` con licenza attiva, `403` senza. Gli id legacy (`ng-*`) sono risolti tramite `legacy_alias` del catalogo, quindi i feed dell'appliance continuano a chiamare i path storici senza modifiche. +- Lo shop attiva e rinnova le licenze con `POST /api/entitlements/activate` (idempotente, indirizzato per `system_key`) e le revoca con `POST /api/entitlements/deactivate`. diff --git a/frontend/.env.example b/frontend/.env.example index 720ea0f59..c71ffe839 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,3 +5,4 @@ VITE_SIGNOUT_REDIRECT_URI=your-signout-redirect-uri VITE_API_BASE_URL=your-api-base-url VITE_LOGTO_ENDPOINT=your-logto-endpoint VITE_LOGTO_APP_ID=your-logto-app-id +VITE_SHOP_BASE_URL=your-nethshop-base-url diff --git a/frontend/Containerfile b/frontend/Containerfile index bade0946a..acad11c39 100644 --- a/frontend/Containerfile +++ b/frontend/Containerfile @@ -27,6 +27,7 @@ ARG VITE_LOGTO_APP_ID ARG VITE_API_BASE_URL ARG VITE_SIGNIN_REDIRECT_URI ARG VITE_SIGNOUT_REDIRECT_URI +ARG VITE_SHOP_BASE_URL # Set environment variables for Vite build ENV VITE_PRODUCT_NAME=$VITE_PRODUCT_NAME @@ -35,6 +36,7 @@ ENV VITE_LOGTO_APP_ID=$VITE_LOGTO_APP_ID ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_SIGNIN_REDIRECT_URI=$VITE_SIGNIN_REDIRECT_URI ENV VITE_SIGNOUT_REDIRECT_URI=$VITE_SIGNOUT_REDIRECT_URI +ENV VITE_SHOP_BASE_URL=$VITE_SHOP_BASE_URL # Build the application RUN npm run build diff --git a/frontend/src/components/dashboard/ThirdPartyAppInfo.vue b/frontend/src/components/dashboard/ThirdPartyAppInfo.vue new file mode 100644 index 000000000..f98f10d61 --- /dev/null +++ b/frontend/src/components/dashboard/ThirdPartyAppInfo.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/components/entitlements/EntitlementsCatalogPanel.vue b/frontend/src/components/entitlements/EntitlementsCatalogPanel.vue new file mode 100644 index 000000000..e9faee769 --- /dev/null +++ b/frontend/src/components/entitlements/EntitlementsCatalogPanel.vue @@ -0,0 +1,560 @@ + + + + + diff --git a/frontend/src/components/entitlements/EntitlementsReportPanel.vue b/frontend/src/components/entitlements/EntitlementsReportPanel.vue new file mode 100644 index 000000000..489d7de16 --- /dev/null +++ b/frontend/src/components/entitlements/EntitlementsReportPanel.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/frontend/src/components/shell/SideMenu.vue b/frontend/src/components/shell/SideMenu.vue index ccf71cacc..920b60edb 100644 --- a/frontend/src/components/shell/SideMenu.vue +++ b/frontend/src/components/shell/SideMenu.vue @@ -22,6 +22,7 @@ import { faUserGroup as fasUserGroup, faServer as fasServer, faTriangleExclamation, + faCertificate, } from '@fortawesome/free-solid-svg-icons' import { faGridOne as fasGridOne } from '@nethesis/nethesis-solid-svg-icons' import { @@ -35,6 +36,7 @@ import { faTriangleExclamation as falTriangleExclamation, } from '@nethesis/nethesis-light-svg-icons' import { + isEntitlementAdmin, canReadApplications, canReadCustomers, canReadDistributors, @@ -67,7 +69,7 @@ const menuExpanded: Ref> = ref({ resellers: false, }) -const systemsManagementRoutes = ['alerts', 'systems', 'applications'] +const systemsManagementRoutes = ['alerts', 'systems', 'applications', 'entitlements-catalog'] const companiesAndUsersRoutes = ['distributors', 'resellers', 'customers', 'users'] const navigation = computed(() => { @@ -102,6 +104,15 @@ const navigation = computed(() => { }) } + if (isEntitlementAdmin()) { + menuItems.push({ + name: 'entitlements-catalog.title', + to: 'entitlements-catalog', + solidIcon: faCertificate, + lightIcon: faCertificate, + }) + } + if (canReadDistributors()) { menuItems.push({ name: 'distributors.title', diff --git a/frontend/src/components/systems/SystemEntitlementsPanel.vue b/frontend/src/components/systems/SystemEntitlementsPanel.vue new file mode 100644 index 000000000..799d2c9cf --- /dev/null +++ b/frontend/src/components/systems/SystemEntitlementsPanel.vue @@ -0,0 +1,756 @@ + + + + + diff --git a/frontend/src/composables/useTabs.ts b/frontend/src/composables/useTabs.ts index 932a32ca4..f34ebc0f5 100644 --- a/frontend/src/composables/useTabs.ts +++ b/frontend/src/composables/useTabs.ts @@ -26,19 +26,36 @@ export function useTabs( const selectedTab = ref('') const currentPath = route.path + // Deep-linked tab not (yet) present in a dynamic tabs list — e.g. tabs + // gated on fetched data. The tab component normalizes an unknown selection + // to the first tab, so without this the deep link would be lost before the + // data arrives. + const pendingTab = ref('') + watch( () => route.query.tab, () => { if (route.path === currentPath) { - selectedTab.value = + const wanted = (route.query.tab as string) ?? initialTabName ?? (tabs.value.length > 0 ? tabs.value[0].name : '') + selectedTab.value = wanted + if (wanted && !tabs.value.some((tab) => tab.name === wanted)) { + pendingTab.value = wanted + } } }, { immediate: true }, ) + watch(tabs, () => { + if (pendingTab.value && tabs.value.some((tab) => tab.name === pendingTab.value)) { + selectedTab.value = pendingTab.value + pendingTab.value = '' + } + }) + watch(selectedTab, () => { router.push({ path: route.path, query: { ...route.query, tab: selectedTab.value } }) }) diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 604b7b73c..3471619e5 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -709,7 +709,7 @@ "active_since": "Active since", "system_key": "System key", "cannot_determine_system_url_description": "Cannot access the system because its URL cannot be determined.", - "checking_reachability": "Checking system reachability\u2026", + "checking_reachability": "Checking system reachability…", "system_unreachable": "The system UI is not publicly reachable. This might happen due to network configuration or security measures.", "change_history_description": "The change history is based on the differences between the inventories sent by the system. If the system has not sent any inventory yet, no change history will be available.", "today": "Today", @@ -1020,5 +1020,104 @@ "ne_tabs": { "tabs": "Tabs", "select_a_tab": "Select a tab" + }, + "entitlements-catalog": { + "title": "Add-ons" + }, + "entitlements": { + "title": "Add-ons", + "catalog_page_description": "The add-on types that can be granted to systems. Services and modules become purchasable on NethShop as soon as they are created. Deleting a type is refused while grants reference it.", + "add_entitlement": "Configure add-on", + "type": "Type", + "application": "Application", + "empty_catalog": "Empty catalog", + "empty_catalog_description": "No add-on types defined yet", + "cannot_retrieve_catalog": "Cannot retrieve catalog", + "delete_catalog_item": "Delete add-on", + "delete_catalog_item_confirmation": "Do you want to delete {name} ({id})? Systems will no longer be able to purchase it.", + "cannot_delete_catalog_item": "Cannot delete add-on", + "catalog_item_deleted": "Add-on deleted", + "catalog_item_created": "Add-on created", + "catalog_item_created_description": "{id} is now purchasable on NethShop", + "cannot_create_catalog_item": "Cannot create add-on", + "add_entitlement_type": "Configure add-on", + "module_name": "Module name", + "module_name_placeholder": "e.g. chat, pec", + "module_id_helper": "Id: {id}", + "display_name": "Display name", + "description_optional": "Description (optional)", + "create": "Create", + "tab_description": "Add-on licenses for this system. Purchases from NethShop appear here automatically once the subscription is active.", + "entitlement": "Add-on", + "valid_from": "Valid from", + "status": "Status", + "system_wide": "System-wide", + "not_purchased": "Not purchased", + "never_expires": "Never expires", + "status_active": "Active", + "status_revoked": "Revoked", + "status_expired": "Expired", + "status_suspended": "Suspended", + "status_pending_payment": "Pending payment", + "buy_on_nethshop": "Buy on NethShop", + "buy_again_on_nethshop": "Buy again on NethShop", + "enable": "Enable", + "restore": "Restore", + "revoke": "Revoke", + "revoke_entitlement": "Revoke add-on", + "revoke_entitlement_confirmation": "Do you want to revoke {name}? The system will lose access to this add-on immediately.", + "cannot_revoke_entitlement": "Cannot revoke add-on", + "entitlement_enabled": "Add-on enabled", + "entitlement_revoked": "Add-on revoked", + "entitlement_restored": "Add-on restored", + "no_entitlements": "No add-ons available", + "no_entitlements_description": "There are no add-ons applicable to this system", + "cannot_retrieve_entitlements": "Cannot retrieve add-ons", + "nethsecurity_addons": "NethSecurity add-ons", + "nethserver_addons": "NethServer add-ons", + "product": "Product", + "service_name": "Service name", + "service_name_placeholder": "e.g. blacklist, sandbox", + "service_name_helper": "The final id will be nsec-", + "module_name_helper": "The final id will be {app}-", + "order": "Order", + "valid_until": "Valid until", + "purchased_by": "Purchased by", + "purchased_by_other_org": "Purchased by another organization", + "renewal_number": "Renewal #{n}", + "report_tab": "Report", + "configuration_tab": "Configuration", + "cannot_retrieve_report": "Cannot retrieve add-on report", + "total_addons": "Total add-ons", + "systems_covered": "Systems covered", + "expiring_in_30d": "Expiring in 30 days", + "days_suffix": "d", + "perpetual_label": "Perpetual", + "total_renewals": "Renewals", + "activations_trend": "Activations (last 12 months)", + "by_addon": "By add-on", + "breakdown": "Breakdown", + "by_organization": "By organization", + "filter_organizations": "Filter organizations", + "filter_tiers": "Filter tiers", + "no_grants_yet": "No add-ons purchased yet", + "in_distributors": "in distributors", + "in_resellers": "in resellers", + "in_customers": "in customers", + "in_owner": "under Nethesis", + "no_report_organizations": "No organizations", + "no_organizations_found": "No organizations found", + "no_tiers_found": "No tiers found", + "organization": "Organization", + "systems_label": "Systems", + "total": "Total", + "renewal_distribution": "Renewal distribution", + "renewals_never": "Never renewed", + "renewals_once": "Renewed once", + "renewals_twice": "Renewed twice", + "renewals_three_plus": "Renewed 3+ times", + "by_tier": "By tier", + "tier": "Tier", + "count_label": "Count" } } diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 8db8e2d70..16b27a1fb 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -1020,5 +1020,104 @@ "ne_tabs": { "tabs": "Schede", "select_a_tab": "Seleziona una scheda" + }, + "entitlements-catalog": { + "title": "Add-on" + }, + "entitlements": { + "title": "Add-on", + "catalog_page_description": "I tipi di add-on assegnabili ai sistemi. Servizi e moduli diventano acquistabili su NethShop appena creati. L'eliminazione di un tipo viene rifiutata finché esistono licenze che lo riferiscono.", + "add_entitlement": "Configura add-on", + "type": "Tipo", + "application": "Applicazione", + "empty_catalog": "Catalogo vuoto", + "empty_catalog_description": "Nessun tipo di add-on definito", + "cannot_retrieve_catalog": "Impossibile recuperare il catalogo", + "delete_catalog_item": "Elimina add-on", + "delete_catalog_item_confirmation": "Vuoi eliminare {name} ({id})? I sistemi non potranno più acquistarlo.", + "cannot_delete_catalog_item": "Impossibile eliminare l'add-on", + "catalog_item_deleted": "Add-on eliminato", + "catalog_item_created": "Add-on creato", + "catalog_item_created_description": "{id} è ora acquistabile su NethShop", + "cannot_create_catalog_item": "Impossibile creare l'add-on", + "add_entitlement_type": "Configura add-on", + "module_name": "Nome modulo", + "module_name_placeholder": "es. chat, pec", + "module_id_helper": "Id: {id}", + "display_name": "Nome visualizzato", + "description_optional": "Descrizione (opzionale)", + "create": "Crea", + "tab_description": "Licenze add-on di questo sistema. Gli acquisti da NethShop compaiono qui automaticamente all'attivazione della subscription.", + "entitlement": "Add-on", + "valid_from": "Valido dal", + "status": "Stato", + "system_wide": "Tutto il sistema", + "not_purchased": "Non acquistato", + "never_expires": "Senza scadenza", + "status_active": "Attivo", + "status_revoked": "Revocato", + "status_expired": "Scaduto", + "status_suspended": "Sospeso", + "status_pending_payment": "In attesa di pagamento", + "buy_on_nethshop": "Acquista su NethShop", + "buy_again_on_nethshop": "Riacquista su NethShop", + "enable": "Abilita", + "restore": "Ripristina", + "revoke": "Revoca", + "revoke_entitlement": "Revoca add-on", + "revoke_entitlement_confirmation": "Vuoi revocare {name}? Il sistema perderà subito l'accesso a questo add-on.", + "cannot_revoke_entitlement": "Impossibile revocare l'add-on", + "entitlement_enabled": "Add-on abilitato", + "entitlement_revoked": "Add-on revocato", + "entitlement_restored": "Add-on ripristinato", + "no_entitlements": "Nessun add-on disponibile", + "no_entitlements_description": "Non ci sono add-on applicabili a questo sistema", + "cannot_retrieve_entitlements": "Impossibile recuperare gli add-on", + "nethsecurity_addons": "Add-on NethSecurity", + "nethserver_addons": "Add-on NethServer", + "product": "Prodotto", + "service_name": "Nome servizio", + "service_name_placeholder": "es. blacklist, sandbox", + "service_name_helper": "L'id finale sarà nsec-", + "module_name_helper": "L'id finale sarà {app}-", + "order": "Ordine", + "valid_until": "Valido fino al", + "purchased_by": "Acquistato da", + "purchased_by_other_org": "Acquistato da un'altra organizzazione", + "renewal_number": "Rinnovo n. {n}", + "report_tab": "Report", + "configuration_tab": "Configurazione", + "cannot_retrieve_report": "Impossibile recuperare il report add-on", + "total_addons": "Add-on totali", + "systems_covered": "Sistemi coperti", + "expiring_in_30d": "In scadenza entro 30 giorni", + "days_suffix": "gg", + "perpetual_label": "Perpetui", + "total_renewals": "Rinnovi", + "activations_trend": "Attivazioni (ultimi 12 mesi)", + "by_addon": "Per add-on", + "breakdown": "Dettaglio", + "by_organization": "Per organizzazione", + "filter_organizations": "Filtra organizzazioni", + "filter_tiers": "Filtra tagli", + "no_grants_yet": "Nessun add-on acquistato", + "in_distributors": "nei distributor", + "in_resellers": "nei reseller", + "in_customers": "nei customer", + "in_owner": "sotto Nethesis", + "no_report_organizations": "Nessuna organizzazione", + "no_organizations_found": "Nessuna organizzazione trovata", + "no_tiers_found": "Nessun taglio trovato", + "organization": "Organizzazione", + "systems_label": "Sistemi", + "total": "Totale", + "renewal_distribution": "Distribuzione rinnovi", + "renewals_never": "Mai rinnovati", + "renewals_once": "Rinnovati una volta", + "renewals_twice": "Rinnovati due volte", + "renewals_three_plus": "Rinnovati 3+ volte", + "by_tier": "Per taglio", + "tier": "Taglio", + "count_label": "Quantità" } } diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index dc98588b8..dc22ef432 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -14,3 +14,7 @@ export const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT export const LOGTO_APP_ID = import.meta.env.VITE_LOGTO_APP_ID export const API_URL = import.meta.env.VITE_API_BASE_URL + +// NethShop base URL (no trailing slash) — where add-on deep-links and order +// links point. Staging shop in dev/qa, live shop in production. +export const SHOP_BASE_URL = import.meta.env.VITE_SHOP_BASE_URL diff --git a/frontend/src/lib/entitlements/entitlements.ts b/frontend/src/lib/entitlements/entitlements.ts new file mode 100644 index 000000000..7e76baecb --- /dev/null +++ b/frontend/src/lib/entitlements/entitlements.ts @@ -0,0 +1,235 @@ +// +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import axios from 'axios' +import { API_URL } from '@/lib/config' +import { useLoginStore } from '@/stores/login' + +export const SYSTEM_ENTITLEMENTS_KEY = 'system_entitlements' +export const ENTITLEMENT_CATALOG_KEY = 'entitlement_catalog' +export const ENTITLEMENT_REPORT_KEY = 'entitlement_report' +export const SYSTEM_ENTITLEMENTS_TABLE_ID = 'systemEntitlements' +// Shop webhooks land asynchronously (pending at checkout, activate on +// completion): poll like the alerts tables so the row status follows along. +export const ENTITLEMENTS_REFETCH_INTERVAL_SECONDS = 10 + +export interface EntitlementCatalogItem { + id: string + display_name: string + description: string + scoped: boolean + kind: 'service' | 'app' | 'module' + system_type?: string + legacy_alias?: string +} + +// Audit snapshot of the my user that BOUGHT the grant on the shop, frozen at +// purchase time (robust to later renames/moves). Absent for manual grants +// and legacy imports; {email} only when the address matches no my user; +// {out_of_scope: true} when the buyer sits outside the viewer's hierarchy +// (redacted server-side). +export interface EntitlementPurchaser { + logto_id?: string + name?: string + email?: string + organization_id?: string + organization_name?: string + org_role?: string + user_roles?: string[] + out_of_scope?: boolean +} + +export interface SystemEntitlement { + id: string + system_id: string + entitlement: string + scope?: string + source: string + source_ref?: string + valid_from: string + valid_until?: string + revoked_at?: string + // who revoked: 'shop' (subscription cancelled / payment failed — the user + // may buy again) or 'manual' (deliberate admin revocation — restore only) + revoked_source?: 'manual' | 'shop' + // shop order placed at checkout, payment not confirmed yet (display-only) + pending_ref?: string + pending_since?: string + active: boolean + // server-computed lifecycle: suspended = the system (or its org) is + // suspended/deleted, the grant itself is untouched; pending = an order is + // awaiting payment, don't offer another purchase + status: 'active' | 'expired' | 'revoked' | 'suspended' | 'pending' + purchased_by?: EntitlementPurchaser + // shop variation (tier) of the purchased product line — display only, the + // add-on mapping stays on the parent product (e.g. label "16-30 device") + variant?: { id?: number; sku?: string; label?: string } + // paid shop orders beyond the first (0 = first period) + renewal_count?: number +} + +// Fleet-wide add-on analytics (owner/Super Admin only): lifecycle totals, +// commercial breakdowns and the 12-month activation trend. +export interface EntitlementReport { + totals: { + total: number + active: number + expired: number + revoked: number + pending: number + suspended: number + perpetual: number + expiring_in_30d: number + expiring_in_60d: number + expiring_in_90d: number + systems: number + organizations: number + // breakdown of `systems` by the owning org's hierarchy role + distributor_systems: number + reseller_systems: number + customer_systems: number + owner_systems: number + total_renewals: number + } + by_entitlement: { + entitlement: string + display_name: string + active: number + expired: number + revoked: number + pending: number + suspended: number + total: number + }[] + renewals: { never: number; once: number; twice: number; three_plus: number } + trend: { month: string; activations: number }[] +} + +// Paginated + searchable slices of the report: organizations can be +// hundreds on the real fleet, so they never travel in the main payload. +export interface EntitlementReportOrg { + organization_id: string + organization_name: string + org_type: string + systems: number + active: number + total: number +} + +export interface EntitlementReportTier { + entitlement: string + label: string + count: number +} + +interface Envelope { + code: number + message: string + data: T +} + +const authHeaders = () => { + const loginStore = useLoginStore() + return { headers: { Authorization: `Bearer ${loginStore.jwtToken}` } } +} + +// What the caller's org may buy on the shop (availability rules set by the +// owner). Drives the "Available on NethShop" section. +export const getAvailableEntitlements = () => + axios + .get< + Envelope<{ available: EntitlementCatalogItem[] }> + >(`${API_URL}/entitlements/available`, authHeaders()) + .then((res) => res.data.data.available) + +export const getEntitlementCatalog = () => + axios + .get< + Envelope<{ catalog: EntitlementCatalogItem[] }> + >(`${API_URL}/entitlements/catalog`, authHeaders()) + .then((res) => res.data.data.catalog) + +export const getEntitlementReport = () => + axios + .get>(`${API_URL}/entitlements/report`, authHeaders()) + .then((res) => res.data.data) + +export const getEntitlementReportOrganizations = (page: number, pageSize: number, search: string) => + axios + .get>( + `${API_URL}/entitlements/report/organizations`, + { + ...authHeaders(), + params: { page, page_size: pageSize, search: search || undefined }, + }, + ) + .then((res) => res.data.data) + +export const getEntitlementReportTiers = (page: number, pageSize: number, search: string) => + axios + .get>( + `${API_URL}/entitlements/report/tiers`, + { + ...authHeaders(), + params: { page, page_size: pageSize, search: search || undefined }, + }, + ) + .then((res) => res.data.data) + +export const getSystemEntitlements = (systemId: string) => + axios + .get< + Envelope<{ entitlements: SystemEntitlement[] }> + >(`${API_URL}/systems/${systemId}/entitlements`, authHeaders()) + .then((res) => res.data.data.entitlements) + +export const createSystemEntitlement = ( + systemId: string, + payload: { entitlement: string; scope?: string; valid_until?: string; source?: string }, +) => + axios + .post< + Envelope + >(`${API_URL}/systems/${systemId}/entitlements`, payload, authHeaders()) + .then((res) => res.data.data) + +export const updateSystemEntitlement = ( + systemId: string, + entitlement: string, + scope: string, + payload: { valid_until?: string; clear_valid_until?: boolean; revoked?: boolean }, +) => + axios + .put< + Envelope + >(`${API_URL}/systems/${systemId}/entitlements/${entitlement}?scope=${encodeURIComponent(scope)}`, payload, authHeaders()) + .then((res) => res.data.data) + +export const revokeSystemEntitlement = (systemId: string, entitlement: string, scope: string) => + axios + .delete< + Envelope + >(`${API_URL}/systems/${systemId}/entitlements/${entitlement}?scope=${encodeURIComponent(scope)}`, authHeaders()) + .then((res) => res.data.data) + +export const createEntitlementCatalogItem = (payload: { + id: string + display_name: string + description?: string + scoped?: boolean + kind?: string + system_type?: string + legacy_alias?: string +}) => + axios + .post< + Envelope + >(`${API_URL}/entitlements/catalog`, payload, authHeaders()) + .then((res) => res.data.data) + +export const deleteEntitlementCatalogItem = (id: string) => + axios + .delete>(`${API_URL}/entitlements/catalog/${id}`, authHeaders()) + .then((res) => res.data) diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 8e480df93..5e6f69de1 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -23,6 +23,28 @@ const DESTROY_USERS = 'destroy:users' const DESTROY_SYSTEMS = 'destroy:systems' const READ_ALERTS = 'read:alerts' const MANAGE_ALERTS = 'manage:alerts' +const READ_ENTITLEMENTS = 'read:entitlements' +const MANAGE_ENTITLEMENTS = 'manage:entitlements' + +export const canReadEntitlements = () => { + const loginStore = useLoginStore() + return loginStore.permissions.includes(READ_ENTITLEMENTS) +} + +export const canManageEntitlements = () => { + const loginStore = useLoginStore() + return loginStore.permissions.includes(MANAGE_ENTITLEMENTS) +} + +// Administrative surface (catalog, manual grants, fleet view): owner org or +// Super Admin only — matches the backend isEntitlementAdmin gate. +export const isEntitlementAdmin = () => { + const loginStore = useLoginStore() + return ( + loginStore.userInfo?.org_role === 'Owner' || + (loginStore.userInfo?.user_roles ?? []).includes('Super Admin') + ) +} export const canReadDistributors = () => { const loginStore = useLoginStore() diff --git a/frontend/src/lib/thirdPartyApps.ts b/frontend/src/lib/thirdPartyApps.ts index e9e6160dc..fff6c8437 100644 --- a/frontend/src/lib/thirdPartyApps.ts +++ b/frontend/src/lib/thirdPartyApps.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later import axios from 'axios' -import { API_URL } from './config' +import { API_URL, SHOP_BASE_URL } from './config' import { useLoginStore } from '@/stores/login' +import { isEntitlementAdmin } from '@/lib/permissions' import { faArrowUpRightFromSquare, faHeadset, @@ -29,11 +30,39 @@ export type ThirdPartyApp = { redirect_uris: string[] post_logout_redirect_uris: string[] login_url: string + // Optional endpoint the app exposes to return a summary widget for the my + // dashboard (config `info_url`). The app owns the content; my renders it. + info_url?: string branding: { display_name: string } } +// Generic widget contract returned by an app's info_url. my renders `items` +// without knowing anything app-specific; the app decides labels and values. +export type ThirdPartyAppWidgetItem = { + label: string + value: string | number + tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' + link?: string +} + +export type ThirdPartyAppInfo = { + status: string + widget?: { items: ThirdPartyAppWidgetItem[] } +} + +// Fetch an app's info_url with the user's Logto ID token (same tenant as the +// app), reusing the OAuth identity instead of a bespoke credential. +export const getThirdPartyAppInfo = (app: ThirdPartyApp) => { + const loginStore = useLoginStore() + return axios + .get(app.info_url as string, { + headers: { Authorization: `Bearer ${loginStore.idToken}` }, + }) + .then((res) => res.data) +} + export const getThirdPartyApps = () => { const loginStore = useLoginStore() @@ -67,7 +96,15 @@ export const getThirdPartyAppDescription = (thirdPartyApp: ThirdPartyApp) => { } export const openThirdPartyApp = (thirdPartyApp: ThirdPartyApp) => { - window.open(thirdPartyApp.login_url, '_blank', 'noopener') + let url = thirdPartyApp.login_url + // Entitlement admins (owner org / Super Admin) are Administrators on the + // shop: land them on the backoffice instead of the storefront. redirect_to + // is honored by the shop's SSO handler (host-whitelisted). + if (thirdPartyApp.name === 'nethshop.nethesis.it' && isEntitlementAdmin()) { + const sep = url.includes('?') ? '&' : '?' + url += `${sep}redirect_to=${encodeURIComponent(`${SHOP_BASE_URL}/wp-admin/`)}` + } + window.open(url, '_blank', 'noopener') } export const sortThirdPartyApps = (app1: ThirdPartyApp, app2: ThirdPartyApp) => { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6a7c29010..e1fe81234 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -22,7 +22,16 @@ fontawesomeConfig.autoAddCss = false const logtoConfig: LogtoConfig = { endpoint: LOGTO_ENDPOINT, appId: LOGTO_APP_ID, - scopes: ['openid', 'profile', 'email'], + scopes: [ + 'openid', + 'profile', + 'email', + // Organization claims in the ID token: third-party app widgets (e.g. + // NethSpot /userinfo) resolve the user's company from these, with the + // same identity contract used by their OIDC login + 'urn:logto:scope:organizations', + 'urn:logto:scope:organization_roles', + ], } const app = createApp(App) diff --git a/frontend/src/queries/systems/entitlements.ts b/frontend/src/queries/systems/entitlements.ts new file mode 100644 index 000000000..2121e9361 --- /dev/null +++ b/frontend/src/queries/systems/entitlements.ts @@ -0,0 +1,127 @@ +// +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import { defineQuery, useQuery } from '@pinia/colada' +import { useDebounceFn } from '@vueuse/core' +import { ref, watch } from 'vue' +import { useRoute } from 'vue-router' +import { useLoginStore } from '@/stores/login' +import { MIN_SEARCH_LENGTH } from '@/lib/common' +import { + ENTITLEMENT_CATALOG_KEY, + ENTITLEMENT_REPORT_KEY, + ENTITLEMENTS_REFETCH_INTERVAL_SECONDS, + SYSTEM_ENTITLEMENTS_KEY, + getAvailableEntitlements, + getEntitlementCatalog, + getEntitlementReport, + getEntitlementReportOrganizations, + getEntitlementReportTiers, + getSystemEntitlements, +} from '@/lib/entitlements/entitlements' + +export const useSystemEntitlements = defineQuery(() => { + const loginStore = useLoginStore() + const route = useRoute() + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [SYSTEM_ENTITLEMENTS_KEY, route.params.systemId as string], + enabled: () => !!loginStore.jwtToken && !!route.params.systemId, + query: () => getSystemEntitlements(route.params.systemId as string), + // Shop webhooks (pending/activate) land asynchronously: keep polling + // like the alerts tables do. + staleTime: ENTITLEMENTS_REFETCH_INTERVAL_SECONDS * 1000, + autoRefetch: true, + }) + return { ...rest, state, asyncStatus } +}) + +export const useAvailableEntitlements = defineQuery(() => { + const loginStore = useLoginStore() + const { state, asyncStatus, ...rest } = useQuery({ + key: () => ['available_entitlements'], + enabled: () => !!loginStore.jwtToken, + query: () => getAvailableEntitlements(), + }) + return { ...rest, state, asyncStatus } +}) + +export const useEntitlementCatalog = defineQuery(() => { + const loginStore = useLoginStore() + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [ENTITLEMENT_CATALOG_KEY], + enabled: () => !!loginStore.jwtToken, + query: () => getEntitlementCatalog(), + }) + return { ...rest, state, asyncStatus } +}) + +export const useEntitlementReport = defineQuery(() => { + const loginStore = useLoginStore() + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [ENTITLEMENT_REPORT_KEY], + enabled: () => !!loginStore.jwtToken, + query: () => getEntitlementReport(), + }) + return { ...rest, state, asyncStatus } +}) + +// Server-paginated + searchable report slice, same shape as the systems +// query composable: debounced text filter, page reset on filter/size change. +function defineReportSliceQuery( + keyName: string, + fetcher: (page: number, pageSize: number, search: string) => Promise, +) { + return defineQuery(() => { + const loginStore = useLoginStore() + const pageNum = ref(1) + const pageSize = ref(10) + const textFilter = ref('') + const debouncedTextFilter = ref('') + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [ + keyName, + { + pageNum: pageNum.value, + pageSize: pageSize.value, + textFilter: debouncedTextFilter.value, + }, + ], + enabled: () => !!loginStore.jwtToken, + query: () => fetcher(pageNum.value, pageSize.value, debouncedTextFilter.value), + placeholderData: (previous) => previous, + }) + + watch( + () => textFilter.value, + useDebounceFn(() => { + // debounce and ignore if text filter is too short + if (textFilter.value.length === 0 || textFilter.value.length >= MIN_SEARCH_LENGTH) { + debouncedTextFilter.value = textFilter.value + pageNum.value = 1 + } + }, 500), + ) + + watch( + () => pageSize.value, + () => { + pageNum.value = 1 + }, + ) + + return { ...rest, state, asyncStatus, pageNum, pageSize, textFilter } + }) +} + +export const useEntitlementReportOrganizations = defineReportSliceQuery( + 'entitlement_report_organizations', + getEntitlementReportOrganizations, +) + +export const useEntitlementReportTiers = defineReportSliceQuery( + 'entitlement_report_tiers', + getEntitlementReportTiers, +) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0364d2b71..1cd7e115f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -75,6 +75,11 @@ const router = createRouter({ name: 'systems', component: () => import('../views/SystemsView.vue'), }, + { + path: '/entitlements-catalog', + name: 'entitlements-catalog', + component: () => import('../views/EntitlementsCatalogView.vue'), + }, { path: '/systems/:systemId', name: 'system_detail', diff --git a/frontend/src/stores/login.ts b/frontend/src/stores/login.ts index 57305b955..c421b6722 100644 --- a/frontend/src/stores/login.ts +++ b/frontend/src/stores/login.ts @@ -37,11 +37,14 @@ export type UserInfo = { } export const useLoginStore = defineStore('login', () => { - const { signIn, signOut, isAuthenticated, getAccessToken } = useLogto() + const { signIn, signOut, isAuthenticated, getAccessToken, getIdToken } = useLogto() const themeStore = useThemeStore() const accessToken = ref('') const jwtToken = ref('') + // Raw Logto ID token (JWT with email): sent to third-party apps' info_url, + // which validate it against the shared Logto tenant. + const idToken = ref('') const refreshToken = ref('') // per-tab bookkeeping: each tab exchanges its own token pair, so refresh // timing must not be shared across tabs (e.g. via localStorage) @@ -71,15 +74,13 @@ export const useLoginStore = defineStore('login', () => { () => { if (isAuthenticated.value) { fetchTokenAndUserInfo() - const pathRequested = useStorage('pathRequested', '') - - if (pathRequested.value) { - router.push(JSON.parse(pathRequested.value)) - pathRequested.value = null // clear the local storage entry - } + // pathRequested (deep link saved by the router guard) is restored by + // LoginRedirectView's sign-in callback: doing it here too would race + // with that push and could bounce the user to the dashboard. } else { jwtToken.value = '' accessToken.value = '' + idToken.value = '' refreshToken.value = '' userInfo.value = undefined } @@ -113,6 +114,13 @@ export const useLoginStore = defineStore('login', () => { } accessToken.value = token || '' + + // Raw ID token for third-party info_url calls (best-effort). + try { + idToken.value = (await getIdToken()) || '' + } catch { + idToken.value = '' + } } catch (error) { console.error('Cannot fetch access token:', error) loadingUserInfo.value = false @@ -270,6 +278,7 @@ export const useLoginStore = defineStore('login', () => { return { isAuthenticated, jwtToken, + idToken, userDisplayName, userInfo, loadingUserInfo, diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index fca19d65a..06f6c4831 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -11,6 +11,7 @@ import DistributorsCounterCard from '@/components/dashboard/DistributorsCounterC import ResellersCounterCard from '@/components/dashboard/ResellersCounterCard.vue' import SystemsCounterCard from '@/components/dashboard/SystemsCounterCard.vue' import UsersCounterCard from '@/components/dashboard/UsersCounterCard.vue' +import ThirdPartyAppInfo from '@/components/dashboard/ThirdPartyAppInfo.vue' import { canReadApplications, canReadCustomers, @@ -18,6 +19,7 @@ import { canReadResellers, canReadSystems, canReadUsers, + isEntitlementAdmin, } from '@/lib/permissions' import { getThirdPartyApps, @@ -92,6 +94,17 @@ const { state: thirdPartyApps } = useQuery({

{{ $t(getThirdPartyAppDescription(thirdPartyApp)) }}

+ + + + + + diff --git a/frontend/src/views/LoginRedirectView.vue b/frontend/src/views/LoginRedirectView.vue index d04135b6c..e66ad9a16 100644 --- a/frontend/src/views/LoginRedirectView.vue +++ b/frontend/src/views/LoginRedirectView.vue @@ -5,8 +5,16 @@ import { NeLink, NeSpinner } from '@nethesis/vue-components' import { ref } from 'vue' useHandleSignInCallback(() => { - // Redirect to home page on successful sign-in - router.push('/') + // Resume the deep link that triggered the login (saved by the router + // guard), or fall back to the home page. Single consumer: restoring it + // anywhere else would race with this push and land on the dashboard. + const pathRequested = localStorage.getItem('pathRequested') + if (pathRequested) { + localStorage.removeItem('pathRequested') + router.push(JSON.parse(pathRequested)) + } else { + router.push('/') + } }) const isRedirectMessageShown = ref(false) diff --git a/frontend/src/views/SystemDetailView.vue b/frontend/src/views/SystemDetailView.vue index 3eecac59a..7e9a13ae0 100644 --- a/frontend/src/views/SystemDetailView.vue +++ b/frontend/src/views/SystemDetailView.vue @@ -15,10 +15,12 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faArrowLeft /*, faArrowUpRightFromSquare*/ } from '@fortawesome/free-solid-svg-icons' import { useSystemDetail } from '@/queries/systems/systemDetail' import { useTabs } from '@/composables/useTabs' +import { computed } from 'vue' import { useI18n } from 'vue-i18n' import SystemOverviewPanel from '@/components/systems/SystemOverviewPanel.vue' import SystemChangeHistoryPanel from '@/components/systems/SystemChangeHistoryPanel.vue' import SystemBackupsPanel from '@/components/systems/SystemBackupsPanel.vue' +import SystemEntitlementsPanel from '@/components/systems/SystemEntitlementsPanel.vue' import SystemAlertsPanel from '@/components/systems/SystemAlertsPanel.vue' import { useLatestInventory } from '@/queries/systems/latestInventory' @@ -26,12 +28,24 @@ const { t } = useI18n() const { state: systemDetail } = useSystemDetail() const { state: latestInventory } = useLatestInventory() // const { state: reachabilityState, asyncStatus: reachabilityAsyncStatus } = useSystemReachability() //// -const { tabs, selectedTab } = useTabs([ - { name: 'overview', label: t('system_detail.overview') }, - { name: 'change_history', label: t('system_detail.change_history') }, - { name: 'alert_history', label: t('alerts.title') }, - { name: 'backups', label: t('backups.title') }, -]) +const { tabs, selectedTab } = useTabs( + computed(() => { + const list = [ + { name: 'overview', label: t('system_detail.overview') }, + { name: 'change_history', label: t('system_detail.change_history') }, + { name: 'alert_history', label: t('alerts.title') }, + { name: 'backups', label: t('backups.title') }, + ] + // The entitlements tab only makes sense once the system has told us what + // it is (type comes from the first inventory): with an unknown type the + // catalog cannot be filtered and the tab would be misleading. + const systemType = systemDetail.value.data?.type + if (systemType === 'nsec' || systemType === 'ns8') { + list.push({ name: 'entitlements', label: t('entitlements.title') }) + } + return list + }), +) //// // const isSystemReachable = computed(() => !!reachabilityState.value.data?.reachable) @@ -131,5 +145,6 @@ const { tabs, selectedTab } = useTabs([ + diff --git a/proxy/Makefile b/proxy/Makefile new file mode 100644 index 000000000..3a4b6214a --- /dev/null +++ b/proxy/Makefile @@ -0,0 +1,77 @@ +CONTAINER_NAME = my-nginx +IMAGE = docker.io/library/nginx:alpine +INTERNAL_PORT = 8443 +CONF = $(shell pwd)/nginx-dev.conf +CERT = $(shell pwd)/my.localtest.me+1.pem +KEY = $(shell pwd)/my.localtest.me+1-key.pem +FWD_PID = /tmp/my-nginx-fwd.pid + +.PHONY: dev-setup dev-up dev-down dev-restart dev-logs dev-status + +## Generate TLS certificates with mkcert (one-time setup) +dev-setup: + @if [ -f $(CERT) ] && [ -f $(KEY) ]; then \ + echo "TLS certificates already exist"; \ + else \ + if ! command -v mkcert >/dev/null 2>&1; then \ + echo "Error: mkcert is not installed."; \ + echo "Install it with: brew install mkcert (macOS) or see https://github.com/FiloSottile/mkcert"; \ + exit 1; \ + fi; \ + echo "Installing local CA (if needed)..."; \ + mkcert -install 2>/dev/null || true; \ + echo "Generating TLS certificates..."; \ + cd $(shell pwd) && mkcert "my.localtest.me" "*.support.my.localtest.me"; \ + echo "Certificates generated: $(CERT) and $(KEY)"; \ + fi + +## Start nginx dev proxy on port 443 (requires sudo for port forwarding) +dev-up: dev-setup + @if podman ps --format '{{.Names}}' | grep -q '^$(CONTAINER_NAME)$$'; then \ + echo "$(CONTAINER_NAME) is already running"; \ + else \ + podman run -d --rm \ + --name $(CONTAINER_NAME) \ + -p $(INTERNAL_PORT):443 \ + -v $(CONF):/etc/nginx/nginx.conf:ro,z \ + -v $(CERT):/etc/nginx/certs/cert.pem:ro,z \ + -v $(KEY):/etc/nginx/certs/key.pem:ro,z \ + $(IMAGE) && \ + echo "$(CONTAINER_NAME) started on port $(INTERNAL_PORT)"; \ + fi + @if [ -f $(FWD_PID) ] && sudo kill -0 $$(cat $(FWD_PID)) 2>/dev/null; then \ + echo "port 443 -> $(INTERNAL_PORT) forwarding is already active"; \ + else \ + echo "Starting port 443 -> $(INTERNAL_PORT) forwarding (requires sudo)..."; \ + sudo python3 $(shell pwd)/port-forward.py $(INTERNAL_PORT) 443 & \ + echo $$! > $(FWD_PID); \ + sleep 0.5; \ + echo "port 443 -> $(INTERNAL_PORT) forwarding active"; \ + fi + +## Stop nginx dev proxy +dev-down: + @if [ -f $(FWD_PID) ]; then \ + sudo kill $$(cat $(FWD_PID)) 2>/dev/null; \ + rm -f $(FWD_PID); \ + echo "port 443 forwarding stopped"; \ + fi + @podman stop $(CONTAINER_NAME) 2>/dev/null && \ + echo "$(CONTAINER_NAME) stopped" || \ + echo "$(CONTAINER_NAME) is not running" + +## Restart nginx dev proxy +dev-restart: dev-down dev-up + +## Show nginx dev proxy logs +dev-logs: + @podman logs -f $(CONTAINER_NAME) + +## Show nginx dev proxy status +dev-status: + @podman ps --filter name=$(CONTAINER_NAME) --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' + @if [ -f $(FWD_PID) ] && sudo kill -0 $$(cat $(FWD_PID)) 2>/dev/null; then \ + echo "port 443 -> $(INTERNAL_PORT) forwarding: active (pid: $$(cat $(FWD_PID)))"; \ + else \ + echo "port 443 -> $(INTERNAL_PORT) forwarding: inactive"; \ + fi diff --git a/proxy/entrypoint.sh b/proxy/entrypoint.sh index 235de0e15..810220e48 100644 --- a/proxy/entrypoint.sh +++ b/proxy/entrypoint.sh @@ -49,8 +49,15 @@ echo "Search domain: ${SEARCH_DOMAIN:-none}" export LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-https://id.nethesis.it}" echo "CSP Logto endpoint: $LOGTO_ENDPOINT" +# Third-party app origins allowed in the CSP connect-src: the dashboard fetches +# each app's info_url (e.g. the NethShop /userinfo widget) directly from the +# browser. Space-separated list of scheme://host origins. Default to the prod +# shop so a missing env var still yields a valid, working policy; QA overrides. +export THIRD_PARTY_CONNECT_SRC="${THIRD_PARTY_CONNECT_SRC:-https://nethshop.nethesis.it}" +echo "CSP third-party connect-src: $THIRD_PARTY_CONNECT_SRC" + echo '==> Substituting nginx config...' -envsubst '$PORT $BACKEND_SERVICE_NAME $COLLECT_SERVICE_NAME $FRONTEND_SERVICE_NAME $RESOLVER $LOGTO_ENDPOINT' < /etc/nginx/nginx.conf > /tmp/nginx.conf +envsubst '$PORT $BACKEND_SERVICE_NAME $COLLECT_SERVICE_NAME $FRONTEND_SERVICE_NAME $RESOLVER $LOGTO_ENDPOINT $THIRD_PARTY_CONNECT_SRC' < /etc/nginx/nginx.conf > /tmp/nginx.conf echo '==> Generated upstream URLs:' grep -E 'set.*upstream' /tmp/nginx.conf || true diff --git a/proxy/nginx-dev.conf b/proxy/nginx-dev.conf new file mode 100644 index 000000000..fa5d88b73 --- /dev/null +++ b/proxy/nginx-dev.conf @@ -0,0 +1,102 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Sanitize token query param from logs + map $request_uri $sanitized_request_uri { + "~^(?[^?]*\?)(?.*)token=[^&]*(?.*)$" "$prefix${before}token=[REDACTED]$after"; + default $request_uri; + } + + log_format main '$remote_addr [$time_local] "$request_method $sanitized_request_uri $server_protocol" ' + '$status $body_bytes_sent "$http_referer"'; + + access_log /dev/stdout main; + error_log /dev/stderr warn; + + # SSL certificates (mkcert) + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + + # Support subdomain proxy — *.support.my.localtest.me + server { + listen 443 ssl; + server_name ~^.+\.support\..*; + + location / { + rewrite ^(.*)$ /support-proxy$1 break; + + proxy_pass http://host.containers.internal:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Long timeouts for support sessions + proxy_connect_timeout 24h; + proxy_send_timeout 24h; + proxy_read_timeout 24h; + } + } + + # Main app — my.localtest.me + server { + listen 443 ssl default_server; + server_name _; + + # Security headers (dev mirror of proxy/nginx.conf so CSP breakage + # shows up locally first; main-app server only, the *.support.* + # server above proxies third-party UIs). Differences from the + # deployed policy: wildcard *.logto.app (dev tenants) and ws: in + # connect-src for the Vite HMR socket. No HSTS: localtest.me only. + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.logto.app https://id.nethesis.it https://qa.id.nethesis.it; font-src 'self' data:; connect-src 'self' ws: wss: https://*.logto.app https://id.nethesis.it https://qa.id.nethesis.it https://nethshop2.nethesis.it https://my.staging.nethspot.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self' https://*.logto.app https://id.nethesis.it https://qa.id.nethesis.it" always; + + # Backend API + location /api/ { + proxy_pass http://host.containers.internal:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + + # WebSocket support (terminal, etc.) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Long timeouts for WebSocket terminal sessions + proxy_connect_timeout 30s; + proxy_send_timeout 24h; + proxy_read_timeout 24h; + } + + # Frontend (Vite dev server) + location / { + proxy_pass http://host.containers.internal:5173; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + + # WebSocket support (Vite HMR) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } +} diff --git a/proxy/nginx.conf b/proxy/nginx.conf index 1c5c7ae7f..4d2710b6d 100644 --- a/proxy/nginx.conf +++ b/proxy/nginx.conf @@ -36,7 +36,10 @@ http { # OIDC fetches (config/token/userinfo) and in img-src for # Logto-hosted avatars; 'unsafe-inline' styles are required by the # index.html loader and runtime-injected component styles. - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ${LOGTO_ENDPOINT}; font-src 'self' data:; connect-src 'self' ${LOGTO_ENDPOINT}; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self' ${LOGTO_ENDPOINT}" always; + # ${THIRD_PARTY_CONNECT_SRC} adds the origins the dashboard calls directly + # from the browser — each third-party app's info_url widget (e.g. the + # NethShop /userinfo summary); config-driven per-env via entrypoint.sh. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ${LOGTO_ENDPOINT}; font-src 'self' data:; connect-src 'self' ${LOGTO_ENDPOINT} ${THIRD_PARTY_CONNECT_SRC}; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self' ${LOGTO_ENDPOINT}" always; # DNS resolver for dynamic upstream resolution (internal Render DNS) resolver ${RESOLVER} valid=30s; diff --git a/proxy/port-forward.py b/proxy/port-forward.py new file mode 100644 index 000000000..f6c1eb183 --- /dev/null +++ b/proxy/port-forward.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Forward TCP traffic from a listen port to a local target port.""" + +import socket +import sys +import threading + + +def forward(src, dst): + try: + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) + except Exception: + pass + finally: + src.close() + dst.close() + + +def handle(client, target_port): + try: + upstream = socket.create_connection(("127.0.0.1", target_port)) + except Exception: + client.close() + return + threading.Thread(target=forward, args=(client, upstream), daemon=True).start() + forward(upstream, client) + + +def main(): + target_port = int(sys.argv[1]) if len(sys.argv) > 1 else 8443 + listen_port = int(sys.argv[2]) if len(sys.argv) > 2 else 443 + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", listen_port)) + srv.listen(128) + print(f"Forwarding port {listen_port} -> {target_port}") + while True: + client, _ = srv.accept() + threading.Thread(target=handle, args=(client, target_port), daemon=True).start() + + +if __name__ == "__main__": + main() diff --git a/render.yaml b/render.yaml index f6516cf49..50dd72cce 100644 --- a/render.yaml +++ b/render.yaml @@ -322,6 +322,8 @@ services: value: "login-redirect" - key: VITE_SIGNOUT_REDIRECT_URI value: "login" + - key: VITE_SHOP_BASE_URL + value: "https://nethshop.nethesis.it" # Production Proxy - Public Entry Point (my.nethesis.it) - type: web @@ -347,6 +349,10 @@ services: - key: LOGTO_ENDPOINT value: https://id.nethesis.it + # Third-party app origins allowed in the CSP connect-src (info_url widgets) + - key: THIRD_PARTY_CONNECT_SRC + value: https://nethshop.nethesis.it https://my.nethspot.com + # ============================================================================= # QA ENVIRONMENT - qa.my.nethesis.it @@ -521,6 +527,8 @@ services: value: "login-redirect" - key: VITE_SIGNOUT_REDIRECT_URI value: "login" + - key: VITE_SHOP_BASE_URL + value: "https://nethshop2.nethesis.it/test" # staging shop for QA testing autoDeploy: true # Auto-deploy on every commit branch: main pullRequestPreviewsEnabled: true # PR previews enabled @@ -555,6 +563,10 @@ services: # Logto endpoint allowed by the Content-Security-Policy - key: LOGTO_ENDPOINT value: https://qa.id.nethesis.it + + # Third-party app origins allowed in the CSP connect-src (info_url widgets) + - key: THIRD_PARTY_CONNECT_SRC + value: https://nethshop2.nethesis.it https://my.staging.nethspot.com autoDeploy: true # Auto-deploy on every commit branch: main pullRequestPreviewsEnabled: true # PR previews enabled