From 21b09c5e180149944d1af62be01c164abadab29f Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 15 Jul 2026 17:12:25 +0200 Subject: [PATCH 01/16] feat: entitlement management with catalog, availability and native /auth Granular add-on licensing replacing the transitional grant-all /auth broker: - entitlement_catalog (DB-driven types: nsec-* services, - modules; legacy_alias keeps the ng-* wire ids the feeds call) + entitlement_availability (optional commercial restriction, default = available to everyone) + system_entitlements (one grant per system, entitlement and application-instance scope; renewals update valid_until in place, revocations keep the row for audit) - backend API: CRUD under /api/systems/:id/entitlements, catalog and availability management, shop-facing activate/deactivate by system_key (idempotent upsert for webhook retries), fleet/hierarchy grants report and stats. Admin surface (catalog, manual grants) = owner org or Super Admin; transactional surface (buy/cancel) = manage:entitlements (Backoffice, Admin, Super Admin); read:entitlements for visibility - collect: native /api/auth[/service/:id|/product/:name] on the existing system Basic auth, alias-aware, with per-instance ?scope= and system-wide fallback - same wire semantics as the legacy my /auth - frontend: Entitlements tab on the system page (flat table for firewalls, two-level per-application-instance table for NS8 clusters, Buy on NethShop deep-links) and Entitlements catalog admin page - docs (en/it) and openapi.yaml aligned Requires migration 037 (manual on prod) and the entitlements resource sync on Logto. --- .../migrations/037_system_entitlements.sql | 98 + .../037_system_entitlements_rollback.sql | 4 + backend/database/schema.sql | 88 + backend/entities/local_system_entitlements.go | 594 ++++++ backend/main.go | 31 + backend/methods/entitlements.go | 804 +++++++++ backend/models/entitlements.go | 161 ++ backend/openapi.yaml | 1597 ++++++++++++++++- collect/README.md | 10 + collect/main.go | 14 + collect/methods/auth.go | 115 ++ docs/docs/features/entitlements.md | 55 + .../current/features/entitlements.md | 55 + frontend/src/components/shell/SideMenu.vue | 13 +- .../systems/SystemEntitlementsPanel.vue | 390 ++++ frontend/src/i18n/en/translation.json | 5 +- frontend/src/i18n/it/translation.json | 3 + frontend/src/lib/entitlements/entitlements.ts | 117 ++ frontend/src/lib/permissions.ts | 22 + frontend/src/queries/systems/entitlements.ts | 46 + frontend/src/router/index.ts | 5 + .../src/views/EntitlementsCatalogView.vue | 326 ++++ frontend/src/views/SystemDetailView.vue | 27 +- 23 files changed, 4513 insertions(+), 67 deletions(-) create mode 100644 backend/database/migrations/037_system_entitlements.sql create mode 100644 backend/database/migrations/037_system_entitlements_rollback.sql create mode 100644 backend/entities/local_system_entitlements.go create mode 100644 backend/methods/entitlements.go create mode 100644 backend/models/entitlements.go create mode 100644 collect/methods/auth.go create mode 100644 docs/docs/features/entitlements.md create mode 100644 docs/i18n/it/docusaurus-plugin-content-docs/current/features/entitlements.md create mode 100644 frontend/src/components/systems/SystemEntitlementsPanel.vue create mode 100644 frontend/src/lib/entitlements/entitlements.ts create mode 100644 frontend/src/queries/systems/entitlements.ts create mode 100644 frontend/src/views/EntitlementsCatalogView.vue diff --git a/backend/database/migrations/037_system_entitlements.sql b/backend/database/migrations/037_system_entitlements.sql new file mode 100644 index 000000000..7d6f0e16a --- /dev/null +++ b/backend/database/migrations/037_system_entitlements.sql @@ -0,0 +1,98 @@ +-- 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, + created_by JSONB, + 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.created_by IS 'Actor snapshot (user/org or shop M2M) that created the grant'; 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..14328e1ea 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -1185,3 +1185,91 @@ 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, + created_by JSONB, + 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.created_by IS 'Actor snapshot (user/org or shop M2M) that created the grant'; diff --git a/backend/entities/local_system_entitlements.go b/backend/entities/local_system_entitlements.go new file mode 100644 index 000000000..5d9139d6f --- /dev/null +++ b/backend/entities/local_system_entitlements.go @@ -0,0 +1,594 @@ +/* +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, created_by, 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 sql.NullTime + var createdBy []byte + + err := scanner.Scan( + &e.ID, &e.SystemID, &e.Entitlement, &e.Scope, &e.Source, &e.SourceRef, + &e.ValidFrom, &validUntil, &revokedAt, &createdBy, &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 len(createdBy) > 0 { + _ = json.Unmarshal(createdBy, &e.CreatedBy) + } + + 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. +func (r *LocalSystemEntitlementRepository) Create(systemID, entitlement, scope, source, sourceRef string, validUntil *time.Time, createdBy map[string]interface{}) (*models.SystemEntitlement, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + + e, err := scanEntitlement(r.db.QueryRow( + `INSERT INTO system_entitlements (system_id, entitlement, scope, source, source_ref, valid_until, created_by) + VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7) + RETURNING `+entitlementColumns, + systemID, entitlement, scope, source, sourceRef, validUntil, createdByJSON)) + 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). +func (r *LocalSystemEntitlementRepository) Update(systemID, entitlement, scope string, setValidUntil bool, validUntil *time.Time, revoked *bool) (*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_at = CASE + WHEN $6::boolean IS NULL THEN revoked_at + WHEN $6 THEN COALESCE(revoked_at, NOW()) + ELSE NULL + END, + updated_at = NOW() + WHERE system_id = $1 AND entitlement = $2 AND scope = $3 + RETURNING `+entitlementColumns, + systemID, entitlement, scope, setValidUntil, validUntil, revoked)) + 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). +func (r *LocalSystemEntitlementRepository) Revoke(systemID, entitlement, scope string) (*models.SystemEntitlement, error) { + revoked := true + return r.Update(systemID, entitlement, scope, false, nil, &revoked) +} + +// 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). +func (r *LocalSystemEntitlementRepository) Upsert(systemID, entitlement, scope, source, sourceRef string, validUntil *time.Time, createdBy map[string]interface{}) (*models.SystemEntitlement, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + + e, err := scanEntitlement(r.db.QueryRow( + `INSERT INTO system_entitlements (system_id, entitlement, scope, source, source_ref, valid_until, created_by) + VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7) + ON CONFLICT (system_id, entitlement, scope) DO UPDATE SET + valid_until = EXCLUDED.valid_until, + revoked_at = NULL, + source = EXCLUDED.source, + source_ref = COALESCE(EXCLUDED.source_ref, system_entitlements.source_ref), + updated_at = NOW() + RETURNING `+entitlementColumns, + systemID, entitlement, scope, source, sourceRef, validUntil, createdByJSON)) + 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 +} + +// 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, e.created_by, 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.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 sql.NullTime + var createdBy []byte + err := rows.Scan( + &row.ID, &row.SystemID, &row.Entitlement, &row.Scope, &row.Source, &row.SourceRef, + &row.ValidFrom, &validUntil, &revokedAt, &createdBy, &row.CreatedAt, &row.UpdatedAt, + &row.Active, + &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 len(createdBy) > 0 { + _ = json.Unmarshal(createdBy, &row.CreatedBy) + } + 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/main.go b/backend/main.go index 42f92258e..61d5d58d3 100644 --- a/backend/main.go +++ b/backend/main.go @@ -247,6 +247,37 @@ 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) + + // 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) + // 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..f27bf9cd6 --- /dev/null +++ b/backend/methods/entitlements.go @@ -0,0 +1,804 @@ +/* +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}$`) + +// 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 +} + +// =========================================== +// 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) + 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 + } + + grant, err := repo.Revoke(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 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)) +} + +// =========================================== +// 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 + } + + c.JSON(http.StatusOK, response.OK("grants retrieved successfully", gin.H{ + "grants": 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, _, 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 + } + + 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.ValidUntil, createdBy) + 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") + + 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) + 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") + + 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) + 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") + + 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..ba57f20a6 --- /dev/null +++ b/backend/models/entitlements.go @@ -0,0 +1,161 @@ +/* +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" +) + +// 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"` +} + +// 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"` + Active bool `json:"active"` + CreatedBy map[string]interface{} `json:"created_by,omitempty"` + 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"` + ValidUntil *time.Time `json:"valid_until,omitempty"` + Source string `json:"source,omitempty"` + SourceRef string `json:"source_ref,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"` +} + +// DeactivateEntitlementRequest revokes a shop-managed grant (subscription +// cancelled/expired). +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"` +} + +// 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..03682e4cc 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,6 +2198,400 @@ components: description: Additional notes or description for the system example: "Production web server for EU region" + # 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: Catalog id (lowercase kebab-case, immutable) + example: "nsec-blacklist" + display_name: + type: string + description: Human-readable name + example: "Blacklist" + description: + type: string + 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 + 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 + 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 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 + example: "2026-07-01T10:00:00Z" + updated_at: + type: string + format: date-time + 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 + description: Logto ID of the creator + example: "53h5zxpwu4vc" + user_name: + type: string + description: Full name of the creator + example: "Edoardo Super" + organization_id: + type: string + description: Organization ID of the creator + example: "lbswt1rxdhbz" + organization_name: + type: string + 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" + + SystemEntitlement: + type: object + 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 + format: uuid + description: Grant ID + example: "f47ac10b-58cc-4372-a567-0e02b2c3d479" + system_id: + type: string + description: ID of the system the grant belongs to + example: "550e8400-e29b-41d4-a716-446655440000" + entitlement: + type: string + description: Catalog id of the granted add-on + example: "nsec-blacklist" + scope: + type: string + description: Application instance the grant is narrowed to (only for scoped catalog items). Omitted = whole system. + example: "nethvoice1" + source: + type: string + description: How the grant was created + enum: [manual, shop, legacy-import] + example: "manual" + source_ref: + type: string + description: Free-form reference to the originating record (e.g. shop subscription id) + example: "sub_12345" + valid_from: + type: string + 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: Expiry of the grant. Null = perpetual. + example: "2027-07-01T10:00:00Z" + revoked_at: + type: string + format: date-time + nullable: true + description: When the grant was revoked. Null = not revoked. + example: null + active: + type: boolean + description: "Derived: not revoked and not expired" + example: true + created_by: + $ref: '#/components/schemas/EntitlementCreator' + created_at: + type: string + format: date-time + example: "2026-07-01T10:00:00Z" + updated_at: + type: string + format: date-time + example: "2026-07-01T10:00:00Z" + + EntitlementAvailability: + type: object + 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: + id: + type: string + format: uuid + description: Rule ID + example: "9f1b7c2e-4a3d-4a2b-8f6e-1c2d3e4f5a6b" + entitlement: + type: string + 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" + + 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." + + EntitlementStatsRow: + type: object + description: Active grants aggregated per entitlement per organization + properties: + entitlement: + type: string + description: Catalog id + example: "nsec-blacklist" + organization_id: + type: string + 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 + + CreateSystemEntitlementRequest: + type: object + 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: + 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_until: + type: string + format: date-time + nullable: true + 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" + + 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: "Catalog id: lowercase kebab-case, convention nsec-, ns8- or -" + example: "nsec-blacklist" + display_name: + type: string + description: Human-readable name + example: "Blacklist" + description: + type: string + 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 + 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" + + 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: + display_name: + type: string + description: Human-readable name + example: "Blacklist" + description: + type: string + 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: Hierarchy role to unlock the item for (mutually exclusive with organization_id) + enum: [distributor, reseller, customer] + example: "reseller" + organization_id: + type: string + 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: Key of the system to activate the add-on on + example: "NETH-F5D2-5E69-A174-45A9-B1AB-2BB9-03F5-F1B4" + entitlement: + type: string + description: Canonical catalog id or legacy alias of the add-on + example: "nsec-blacklist" + scope: + type: string + 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: Expiry of the grant. Null/omitted = perpetual. + example: "2027-07-01T10:00:00Z" + source_ref: + type: string + description: Free-form reference to the originating record (e.g. shop subscription id) + example: "sub_12345" + + DeactivateEntitlementRequest: + type: object + description: Shop-facing revocation of a grant (subscription cancelled/expired) + required: + - system_key + - entitlement + properties: + system_key: + type: string + 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: Canonical catalog id or legacy alias of the add-on + example: "nsec-blacklist" + scope: + type: string + description: Application instance the grant is narrowed to + example: "nethvoice1" + source_ref: + type: string + description: Free-form reference to the originating record + example: "sub_12345" + # Applications schemas Application: type: object @@ -9869,72 +10267,987 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - # =========================================== - # ALERTING ENDPOINTS (Collect - Mimir Proxy) - # =========================================== + # =========================================================================== + # ENTITLEMENTS (Granular add-on licensing) + # =========================================================================== - /alerts/totals: + /systems/{id}/entitlements: get: - operationId: getAlertsTotals + operationId: listSystemEntitlements tags: - - Backend - Alerts - summary: "/alerts/totals - Get alert totals by severity" + - Backend - Entitlements + summary: /systems/{id}/entitlements - List the add-on grants of a system description: | - Returns active alert counts by severity and total resolved alert history - count. Requires `read:systems` permission. - - **Scope modes** (selected by query params): - - | `organization_id` | `include` | Result | - |---|---|---| - | omitted | — | Caller's full hierarchy (recursive). For Customer it's just self. | - | `X` | omitted | Single tenant `X` only. Resellers/Distributors hold no alerts on their own tenant — those live on their customer tenants — so single-tenant queries on a non-leaf org typically return zero. | - | `X` (repeated for multi) | omitted | Union of all `organization_id` values passed. Each must be in the caller's hierarchy (Owner exempt). | - | `X` (single or multi) | `descendants` | Each org_id is expanded to itself + its sub-tree (deduplicated). Use this to drill into one or more sub-trees. | - - Active counts come from `alerts_totals_by_org`, a per-organization - pre-aggregated table maintained by the `collect` service's - `AlertsTotalsRefresher` cron (one Mimir round-trip per tenant every 60s, - off the user request path). The endpoint resolves the response with a - single SQL `SUM` scoped to the caller's hierarchy, so latency is - independent of how many tenants are in scope. The `history` total comes - from a single SQL query against `alert_history` scoped to the same set - of organization IDs. - - When the freshest row in scope is older than 5 minutes (refresher - lagging or down), the response carries a `warnings[]` entry - (`totals: stale data, oldest refresh Xs ago`); counts are still served - as last known. DB errors on either table are likewise non-fatal and - surface as `warnings[]` entries. - - Customer callers are always pinned to their own organization regardless - of `organization_id`/`include`. - security: - - BearerAuth: [] + 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: organization_id - in: query - description: | - Target organization ID(s). Repeat the param to pass multiple values - (`?organization_id=A&organization_id=B`). Optional for all roles except - Customer (where it is ignored). Distributors/Resellers receive `403` if any - value is not in their hierarchy. - 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 full sub-tree (results deduplicated). Ignored when `organization_id` - is omitted (the caller's own hierarchy is already used) and when the caller - is a Customer. + - name: id + in: path + required: true + description: System ID schema: type: string - enum: [descendants] + example: "sys_123456789" + responses: + '200': + description: Entitlements retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlements retrieved successfully" + data: + type: object + 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' + + post: + operationId: createSystemEntitlement + tags: + - 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 + required: true + description: System ID + schema: + type: string + example: "sys_123456789" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSystemEntitlementRequest' + responses: + '201': + description: Entitlement created successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "entitlement created successfully" + data: + $ref: '#/components/schemas/SystemEntitlement' + '400': + 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}/entitlements/{entitlement}: + put: + operationId: updateSystemEntitlement + tags: + - 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 + required: true + description: System ID + 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: Entitlement updated successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement updated successfully" + data: + $ref: '#/components/schemas/SystemEntitlement' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: System not found, or no grant for this (entitlement, scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + operationId: deleteSystemEntitlement + tags: + - Backend - Entitlements + summary: /systems/{id}/entitlements/{entitlement} - Revoke a grant + description: | + 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 + required: true + description: System ID + 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: Entitlement revoked successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement revoked successfully" + data: + $ref: '#/components/schemas/SystemEntitlement' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: System not found, or no grant for this (entitlement, scope) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/catalog: + get: + operationId: listEntitlementCatalog + tags: + - 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: Entitlement catalog retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement catalog retrieved successfully" + data: + type: object + properties: + catalog: + type: array + items: + $ref: '#/components/schemas/EntitlementCatalogItem' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $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: + '201': + description: Catalog item created successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "catalog item created successfully" + data: + $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' + '409': + description: Catalog item already exists + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 409 + message: + type: string + example: "catalog item already exists" + data: + type: object + nullable: true + example: null + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/catalog/{id}: + put: + operationId: updateEntitlementCatalogItem + tags: + - Backend - Entitlements + summary: /entitlements/catalog/{id} - Update an add-on type + description: | + 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: id + in: path + required: true + description: Catalog id + schema: + type: string + example: "nsec-blacklist" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateEntitlementCatalogRequest' + responses: + '200': + description: Catalog item updated successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "catalog item updated successfully" + data: + $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' + + delete: + operationId: deleteEntitlementCatalogItem + tags: + - 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: Catalog id + schema: + type: string + example: "nsec-blacklist" + responses: + '200': + description: Catalog item deleted successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "catalog item deleted successfully" + data: + type: object + 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' + + /entitlements/catalog/{id}/availability: + get: + operationId: listEntitlementAvailability + tags: + - 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: Catalog id + schema: + type: string + example: "nsec-blacklist" + responses: + '200': + description: Availability retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "availability retrieved successfully" + data: + type: object + 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' + + post: + operationId: createEntitlementAvailability + tags: + - 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: Catalog id + schema: + type: string + example: "nsec-blacklist" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEntitlementAvailabilityRequest' + responses: + '201': + description: Availability rule created successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "availability rule created successfully" + data: + $ref: '#/components/schemas/EntitlementAvailability' + '400': + 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' + + /entitlements/catalog/{id}/availability/{rule_id}: + delete: + operationId: deleteEntitlementAvailability + tags: + - 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: Catalog id + schema: + type: string + 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: Availability rule removed successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "availability rule removed 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' + + /entitlements/available: + get: + operationId: listAvailableEntitlements + tags: + - 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: Available entitlements retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "available entitlements retrieved successfully" + data: + type: object + properties: + available: + type: array + items: + $ref: '#/components/schemas/EntitlementCatalogItem' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/grants: + get: + operationId: getEntitlementGrants + tags: + - 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: entitlement + in: query + description: Filter by catalog id + schema: + type: string + example: "nsec-blacklist" + - name: organization_id + in: query + description: Filter by the organization owning the system + schema: + type: string + example: "akkbs6x2wo82" + - name: source + in: query + description: Filter by grant source + schema: + type: string + enum: [manual, shop, legacy-import] + - name: active + in: query + description: When "true", only active grants (not revoked, not expired) + schema: + type: string + enum: ["true"] + - name: expiring_before + in: query + description: Only grants expiring before this RFC3339 timestamp + schema: + type: string + format: date-time + example: "2026-12-31T23:59:59Z" + - name: page + in: query + description: Page number + schema: + type: integer + default: 1 + - name: page_size + in: query + description: Page size (max 200) + schema: + type: integer + default: 50 + maximum: 200 + responses: + '200': + description: Grants retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "grants retrieved successfully" + data: + type: object + properties: + grants: + type: array + items: + $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' + '500': + $ref: '#/components/responses/InternalServerError' + + /entitlements/stats: + get: + operationId: getEntitlementStats + tags: + - 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: Entitlement stats retrieved successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement stats retrieved successfully" + data: + type: object + properties: + stats: + type: array + items: + $ref: '#/components/schemas/EntitlementStatsRow' + '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/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. + 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' + + # =========================================== + # ALERTING ENDPOINTS (Collect - Mimir Proxy) + # =========================================== + + /alerts/totals: + get: + operationId: getAlertsTotals + tags: + - Backend - Alerts + summary: "/alerts/totals - Get alert totals by severity" + description: | + Returns active alert counts by severity and total resolved alert history + count. Requires `read:systems` permission. + + **Scope modes** (selected by query params): + + | `organization_id` | `include` | Result | + |---|---|---| + | omitted | — | Caller's full hierarchy (recursive). For Customer it's just self. | + | `X` | omitted | Single tenant `X` only. Resellers/Distributors hold no alerts on their own tenant — those live on their customer tenants — so single-tenant queries on a non-leaf org typically return zero. | + | `X` (repeated for multi) | omitted | Union of all `organization_id` values passed. Each must be in the caller's hierarchy (Owner exempt). | + | `X` (single or multi) | `descendants` | Each org_id is expanded to itself + its sub-tree (deduplicated). Use this to drill into one or more sub-trees. | + + Active counts come from `alerts_totals_by_org`, a per-organization + pre-aggregated table maintained by the `collect` service's + `AlertsTotalsRefresher` cron (one Mimir round-trip per tenant every 60s, + off the user request path). The endpoint resolves the response with a + single SQL `SUM` scoped to the caller's hierarchy, so latency is + independent of how many tenants are in scope. The `history` total comes + from a single SQL query against `alert_history` scoped to the same set + of organization IDs. + + When the freshest row in scope is older than 5 minutes (refresher + lagging or down), the response carries a `warnings[]` entry + (`totals: stale data, oldest refresh Xs ago`); counts are still served + as last known. DB errors on either table are likewise non-fatal and + surface as `warnings[]` entries. + + Customer callers are always pinned to their own organization regardless + of `organization_id`/`include`. + security: + - BearerAuth: [] + parameters: + - name: organization_id + in: query + description: | + Target organization ID(s). Repeat the param to pass multiple values + (`?organization_id=A&organization_id=B`). Optional for all roles except + Customer (where it is ignored). Distributors/Resellers receive `403` if any + value is not in their hierarchy. + 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 full sub-tree (results deduplicated). Ignored when `organization_id` + is omitted (the caller's own hierarchy is already used) and when the caller + is a Customer. + schema: + type: string + enum: [descendants] responses: '200': description: Alert totals retrieved @@ -14658,3 +15971,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/src/components/shell/SideMenu.vue b/frontend/src/components/shell/SideMenu.vue index ccf71cacc..72df2aa28 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', + 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..2a28c3c56 --- /dev/null +++ b/frontend/src/components/systems/SystemEntitlementsPanel.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 604b7b73c..aaa7b640f 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,8 @@ "ne_tabs": { "tabs": "Tabs", "select_a_tab": "Select a tab" + }, + "entitlements-catalog": { + "title": "Entitlements" } } diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 8db8e2d70..389027348 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -1020,5 +1020,8 @@ "ne_tabs": { "tabs": "Schede", "select_a_tab": "Seleziona una scheda" + }, + "entitlements-catalog": { + "title": "Entitlements" } } diff --git a/frontend/src/lib/entitlements/entitlements.ts b/frontend/src/lib/entitlements/entitlements.ts new file mode 100644 index 000000000..1a6b0ca57 --- /dev/null +++ b/frontend/src/lib/entitlements/entitlements.ts @@ -0,0 +1,117 @@ +// +// 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 interface EntitlementCatalogItem { + id: string + display_name: string + description: string + scoped: boolean + kind: 'service' | 'app' | 'module' + system_type?: string + legacy_alias?: string +} + +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 + active: boolean +} + +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 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/queries/systems/entitlements.ts b/frontend/src/queries/systems/entitlements.ts new file mode 100644 index 000000000..b4bde2aa4 --- /dev/null +++ b/frontend/src/queries/systems/entitlements.ts @@ -0,0 +1,46 @@ +// +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import { defineQuery, useQuery } from '@pinia/colada' +import { useRoute } from 'vue-router' +import { useLoginStore } from '@/stores/login' +import { + ENTITLEMENT_CATALOG_KEY, + SYSTEM_ENTITLEMENTS_KEY, + getAvailableEntitlements, + getEntitlementCatalog, + 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), + }) + 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 } +}) 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/views/EntitlementsCatalogView.vue b/frontend/src/views/EntitlementsCatalogView.vue new file mode 100644 index 000000000..24bdc5a1e --- /dev/null +++ b/frontend/src/views/EntitlementsCatalogView.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/frontend/src/views/SystemDetailView.vue b/frontend/src/views/SystemDetailView.vue index 3eecac59a..36f1ade1e 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: 'Entitlements' }) + } + return list + }), +) //// // const isSystemReachable = computed(() => !!reachabilityState.value.data?.reachable) @@ -131,5 +145,6 @@ const { tabs, selectedTab } = useTabs([ + From 60c179992389697a66b2f3f95f28152a3a48c939 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 16 Jul 2026 09:01:49 +0200 Subject: [PATCH 02/16] feat(entitlements): grant lifecycle states, add-on UI, shop config Backend: server-computed status (active/expired/revoked/suspended/ pending), revoked_source, pending activation endpoint, valid_from override for import; migration 037 + schema + openapi aligned. Frontend: "Add-ons" UI (catalog split by product with per-instance grouping, lifecycle badges, clickable NethShop order link), deep-link tab + post-login redirect fixes, full en/it i18n. Shop base URL is now VITE_SHOP_BASE_URL (config + env.example + render.yaml + release workflow + Containerfile) instead of hardcoded. --- .github/workflows/release-production.yml | 1 + .../migrations/037_system_entitlements.sql | 5 + backend/database/schema.sql | 5 + backend/entities/local_system_entitlements.go | 130 +++- backend/main.go | 1 + backend/methods/entitlements.go | 148 ++++- backend/models/entitlements.go | 88 ++- backend/openapi.yaml | 128 +++- frontend/.env.example | 1 + frontend/Containerfile | 2 + frontend/src/components/shell/SideMenu.vue | 2 +- .../systems/SystemEntitlementsPanel.vue | 541 ++++++++++++----- frontend/src/composables/useTabs.ts | 19 +- frontend/src/i18n/en/translation.json | 63 +- frontend/src/i18n/it/translation.json | 63 +- frontend/src/lib/config.ts | 4 + frontend/src/lib/entitlements/entitlements.ts | 14 + frontend/src/queries/systems/entitlements.ts | 5 + frontend/src/stores/login.ts | 9 +- .../src/views/EntitlementsCatalogView.vue | 556 +++++++++++++----- frontend/src/views/LoginRedirectView.vue | 12 +- frontend/src/views/SystemDetailView.vue | 2 +- render.yaml | 4 + 23 files changed, 1449 insertions(+), 354 deletions(-) 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/backend/database/migrations/037_system_entitlements.sql b/backend/database/migrations/037_system_entitlements.sql index 7d6f0e16a..94629901b 100644 --- a/backend/database/migrations/037_system_entitlements.sql +++ b/backend/database/migrations/037_system_entitlements.sql @@ -79,6 +79,9 @@ CREATE TABLE IF NOT EXISTS system_entitlements ( 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, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), @@ -95,4 +98,6 @@ COMMENT ON COLUMN system_entitlements.source IS 'How the grant was created: 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'; diff --git a/backend/database/schema.sql b/backend/database/schema.sql index 14328e1ea..15d7f7abe 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -1256,6 +1256,9 @@ CREATE TABLE IF NOT EXISTS system_entitlements ( 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, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), @@ -1272,4 +1275,6 @@ COMMENT ON COLUMN system_entitlements.source IS 'How the grant was created: 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'; diff --git a/backend/entities/local_system_entitlements.go b/backend/entities/local_system_entitlements.go index 5d9139d6f..1bab5c576 100644 --- a/backend/entities/local_system_entitlements.go +++ b/backend/entities/local_system_entitlements.go @@ -282,17 +282,19 @@ func NewLocalSystemEntitlementRepository() *LocalSystemEntitlementRepository { const entitlementColumns = ` id, system_id, entitlement, scope, source, COALESCE(source_ref, ''), - valid_from, valid_until, revoked_at, created_by, created_at, updated_at, + valid_from, valid_until, revoked_at, COALESCE(revoked_source, ''), + COALESCE(pending_ref, ''), pending_since, created_by, 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 sql.NullTime + var validUntil, revokedAt, pendingSince sql.NullTime var createdBy []byte err := scanner.Scan( &e.ID, &e.SystemID, &e.Entitlement, &e.Scope, &e.Source, &e.SourceRef, - &e.ValidFrom, &validUntil, &revokedAt, &createdBy, &e.CreatedAt, &e.UpdatedAt, + &e.ValidFrom, &validUntil, &revokedAt, &e.RevokedSource, + &e.PendingRef, &pendingSince, &createdBy, &e.CreatedAt, &e.UpdatedAt, &e.Active, ) if err != nil { @@ -307,10 +309,18 @@ func scanEntitlement(scanner interface{ Scan(...interface{}) error }) (*models.S t := revokedAt.Time e.RevokedAt = &t } + if pendingSince.Valid { + t := pendingSince.Time + e.PendingSince = &t + } if len(createdBy) > 0 { _ = json.Unmarshal(createdBy, &e.CreatedBy) } + // 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 } @@ -355,17 +365,19 @@ func (r *LocalSystemEntitlementRepository) Get(systemID, entitlement, scope stri // Create grants an entitlement to a system. Duplicate (system, entitlement, // scope) tuples return ErrEntitlementExists — renewals must Update the // existing row. -func (r *LocalSystemEntitlementRepository) Create(systemID, entitlement, scope, source, sourceRef string, validUntil *time.Time, createdBy map[string]interface{}) (*models.SystemEntitlement, error) { +func (r *LocalSystemEntitlementRepository) Create(systemID, entitlement, scope, source, sourceRef string, validFrom, validUntil *time.Time, createdBy map[string]interface{}) (*models.SystemEntitlement, error) { var createdByJSON []byte if createdBy != nil { createdByJSON, _ = json.Marshal(createdBy) } + // 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_until, created_by) - VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7) + `INSERT INTO system_entitlements (system_id, entitlement, scope, source, source_ref, valid_from, valid_until, created_by) + VALUES ($1, $2, $3, $4, NULLIF($5, ''), COALESCE($6, now()), $7, $8) RETURNING `+entitlementColumns, - systemID, entitlement, scope, source, sourceRef, validUntil, createdByJSON)) + systemID, entitlement, scope, source, sourceRef, validFrom, validUntil, createdByJSON)) if err != nil { if pqErr, ok := err.(*pq.Error); ok { switch pqErr.Code { @@ -383,20 +395,29 @@ func (r *LocalSystemEntitlementRepository) Create(systemID, entitlement, scope, // 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). -func (r *LocalSystemEntitlementRepository) Update(systemID, entitlement, scope string, setValidUntil bool, validUntil *time.Time, revoked *bool) (*models.SystemEntitlement, error) { +// 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)) + systemID, entitlement, scope, setValidUntil, validUntil, revoked, revokedSource)) if err == sql.ErrNoRows { return nil, ErrEntitlementNotFound } @@ -407,9 +428,10 @@ func (r *LocalSystemEntitlementRepository) Update(systemID, entitlement, scope s } // Revoke marks the entitlement as revoked (idempotent, row kept for audit). -func (r *LocalSystemEntitlementRepository) Revoke(systemID, entitlement, scope string) (*models.SystemEntitlement, error) { +// 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) + return r.Update(systemID, entitlement, scope, false, nil, &revoked, source) } // Upsert creates the grant or, when the (system, entitlement, scope) row @@ -427,7 +449,12 @@ func (r *LocalSystemEntitlementRepository) Upsert(systemID, entitlement, scope, VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7) 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), updated_at = NOW() @@ -442,6 +469,66 @@ func (r *LocalSystemEntitlementRepository) Upsert(systemID, entitlement, scope, 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. +func (r *LocalSystemEntitlementRepository) MarkPending(systemID, entitlement, scope, ref string, createdBy map[string]interface{}) (*models.SystemEntitlement, error) { + var createdByJSON []byte + if createdBy != nil { + createdByJSON, _ = json.Marshal(createdBy) + } + + 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) + VALUES ($1, $2, $3, 'shop', $4, NOW(), NOW(), $4, NOW(), $5) + 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)) + 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) { @@ -517,8 +604,10 @@ func (r *LocalSystemEntitlementRepository) ListGrants(f GrantsReportFilter, limi 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, e.created_by, e.created_at, e.updated_at, + e.valid_from, e.valid_until, e.revoked_at, COALESCE(e.revoked_source, ''), + COALESCE(e.pending_ref, ''), e.pending_since, e.created_by, 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+` @@ -534,12 +623,14 @@ func (r *LocalSystemEntitlementRepository) ListGrants(f GrantsReportFilter, limi out := []*models.EntitlementGrantReportRow{} for rows.Next() { var row models.EntitlementGrantReportRow - var validUntil, revokedAt sql.NullTime + var validUntil, revokedAt, pendingSince sql.NullTime var createdBy []byte + var systemSuspended bool err := rows.Scan( &row.ID, &row.SystemID, &row.Entitlement, &row.Scope, &row.Source, &row.SourceRef, - &row.ValidFrom, &validUntil, &revokedAt, &createdBy, &row.CreatedAt, &row.UpdatedAt, - &row.Active, + &row.ValidFrom, &validUntil, &revokedAt, &row.RevokedSource, + &row.PendingRef, &pendingSince, &createdBy, &row.CreatedAt, &row.UpdatedAt, + &row.Active, &systemSuspended, &row.SystemName, &row.SystemKey, &row.OrganizationID, &row.OrganizationName, ) if err != nil { @@ -553,9 +644,16 @@ func (r *LocalSystemEntitlementRepository) ListGrants(f GrantsReportFilter, limi t := revokedAt.Time row.RevokedAt = &t } + if pendingSince.Valid { + t := pendingSince.Time + row.PendingSince = &t + } if len(createdBy) > 0 { _ = json.Unmarshal(createdBy, &row.CreatedBy) } + // 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() diff --git a/backend/main.go b/backend/main.go index 61d5d58d3..7d4837abe 100644 --- a/backend/main.go +++ b/backend/main.go @@ -277,6 +277,7 @@ func main() { // 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) diff --git a/backend/methods/entitlements.go b/backend/methods/entitlements.go index f27bf9cd6..7d34fc634 100644 --- a/backend/methods/entitlements.go +++ b/backend/methods/entitlements.go @@ -28,6 +28,13 @@ import ( // -; 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). @@ -492,7 +499,54 @@ func DeactivateEntitlement(c *gin.Context) { return } - grant, err := repo.Revoke(systemID, item.ID, req.Scope) + 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 @@ -514,6 +568,83 @@ func DeactivateEntitlement(c *gin.Context) { 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) + 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 // =========================================== @@ -632,6 +763,12 @@ func ListSystemEntitlements(c *gin.Context) { return } + if isSystemBlocked(system) { + for _, e := range list { + e.Status = models.EntitlementStatus(e.Active, e.RevokedAt, true, e.PendingRef) + } + } + c.JSON(http.StatusOK, response.OK("entitlements retrieved successfully", gin.H{ "entitlements": list, })) @@ -695,7 +832,7 @@ func CreateSystemEntitlement(c *gin.Context) { } repo := entities.NewLocalSystemEntitlementRepository() - entitlement, err := repo.Create(systemID, req.Entitlement, req.Scope, source, req.SourceRef, req.ValidUntil, createdBy) + entitlement, err := repo.Create(systemID, req.Entitlement, req.Scope, source, req.SourceRef, req.ValidFrom, req.ValidUntil, createdBy) if err == entities.ErrEntitlementExists { c.JSON(http.StatusConflict, response.Conflict("entitlement already exists for this system", nil)) return @@ -717,6 +854,7 @@ func CreateSystemEntitlement(c *gin.Context) { 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)) } @@ -743,7 +881,7 @@ func UpdateSystemEntitlement(c *gin.Context) { } repo := entities.NewLocalSystemEntitlementRepository() - entitlement, err := repo.Update(systemID, entitlementID, scope, setValidUntil, validUntil, req.Revoked) + 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 @@ -764,6 +902,7 @@ func UpdateSystemEntitlement(c *gin.Context) { 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)) } @@ -779,7 +918,7 @@ func DeleteSystemEntitlement(c *gin.Context) { scope := c.Query("scope") repo := entities.NewLocalSystemEntitlementRepository() - entitlement, err := repo.Revoke(systemID, entitlementID, scope) + 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 @@ -800,5 +939,6 @@ func DeleteSystemEntitlement(c *gin.Context) { 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 index ba57f20a6..41d69cf14 100644 --- a/backend/models/entitlements.go +++ b/backend/models/entitlements.go @@ -14,6 +14,41 @@ const ( 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 @@ -102,19 +137,30 @@ type EntitlementStatsRow struct { // 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"` - Active bool `json:"active"` - CreatedBy map[string]interface{} `json:"created_by,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreateSystemEntitlementRequest grants an add-on to a system. Scope narrows @@ -123,6 +169,7 @@ type SystemEntitlement struct { 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"` @@ -142,7 +189,10 @@ type ActivateEntitlementRequest struct { } // DeactivateEntitlementRequest revokes a shop-managed grant (subscription -// cancelled/expired). +// 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"` @@ -150,6 +200,16 @@ type DeactivateEntitlementRequest struct { 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"` +} + // 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 diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 03682e4cc..19e82b69b 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2324,10 +2324,39 @@ components: nullable: true description: When the grant was revoked. Null = not revoked. example: null + revoked_source: + type: string + 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: "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: 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' created_at: @@ -2431,6 +2460,11 @@ components: 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 @@ -2589,9 +2623,34 @@ components: example: "nethvoice1" source_ref: type: string - description: Free-form reference to the originating record + 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: Key of the system the add-on was bought for + example: "NETH-F5D2-5E69-A174-45A9-B1AB-2BB9-03F5-F1B4" + entitlement: + type: string + description: Canonical catalog id or legacy alias of the add-on + example: "nethvoice-chat" + scope: + type: string + 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" + # Applications schemas Application: type: object @@ -11130,6 +11189,67 @@ paths: '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: Entitlement activation marked pending + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "entitlement activation marked pending" + data: + $ref: '#/components/schemas/SystemEntitlement' + '400': + 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 @@ -11141,6 +11261,12 @@ paths: 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: 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/shell/SideMenu.vue b/frontend/src/components/shell/SideMenu.vue index 72df2aa28..920b60edb 100644 --- a/frontend/src/components/shell/SideMenu.vue +++ b/frontend/src/components/shell/SideMenu.vue @@ -106,7 +106,7 @@ const navigation = computed(() => { if (isEntitlementAdmin()) { menuItems.push({ - name: 'Entitlements', + name: 'entitlements-catalog.title', to: 'entitlements-catalog', solidIcon: faCertificate, lightIcon: faCertificate, diff --git a/frontend/src/components/systems/SystemEntitlementsPanel.vue b/frontend/src/components/systems/SystemEntitlementsPanel.vue index 2a28c3c56..154283a30 100644 --- a/frontend/src/components/systems/SystemEntitlementsPanel.vue +++ b/frontend/src/components/systems/SystemEntitlementsPanel.vue @@ -3,22 +3,27 @@ SPDX-License-Identifier: GPL-3.0-or-later - One single table: - - system-wide services pertinent to the system type: purchased ones show - payment/renewal info, the others show a "Buy on NethShop" row action; - - on NS8 clusters a second level lists the application instances found on - the system (nethvoice1, nethvoice2, ...) and, under each instance, the - modules available for that application: purchased (with subscription - references) or buyable. + One table, styled like the alerts tables (auto-refresh, client-side + pagination, skeleton loading): + - system-wide services pertinent to the system type come first; + - on NS8 clusters the add-ons are grouped by application INSTANCE: a header + row per instance (nethvoice1, nethvoice2, ...) with every applicable + module add-on underneath, each with its own shop status. + Pagination counts the add-on entries; group headers are re-inserted on the + visible page. Purchased rows show payment/renewal info; the others a "Buy + on NethShop" action (or "Enable" for entitlement admins). -->