diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index debbdc29..8e055c58 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -1713,6 +1713,19 @@ func main() {
// must be registered BEFORE /assets/:id, or "statistics" is parsed as an
// asset UUID and the route answers 400 for a request that is perfectly valid.
protected.Get("/assets/statistics", assetRead, assetHandler.GetAssetStatistics)
+ // #861 — CSV import of the inventory: every row or none, one transaction,
+ // and the plan cap checked against the whole file.
+ assetImportHandler := handlers.NewAssetImportHandler(
+ assetapp.NewImportAssetsUseCase(repository.RunAssetTx(database.DB), repository.ListAssetNames(database.DB)).
+ WithActivation(activationRecorder).
+ WithCapacity(func(ctx context.Context, tenant uuid.UUID) (int, error) {
+ _, limit, used, _, err := entitlementService.Capacity(ctx, tenant, ent.LimitAssets)
+ if err != nil || limit == ent.Unlimited || used < 0 {
+ return -1, err
+ }
+ return max(limit-used, 0), nil
+ }))
+ protected.Post("/assets/import", assetCreate, capAssets, assetImportHandler.ImportAssets)
protected.Get("/asset-dependencies", assetRead, assetDepHandler.ListAssetDependencies)
protected.Post("/asset-dependencies", assetUpdate, assetDepHandler.CreateAssetDependency)
protected.Delete("/asset-dependencies/:id", assetUpdate, assetDepHandler.DeleteAssetDependency)
diff --git a/backend/internal/application/asset/create_asset.go b/backend/internal/application/asset/create_asset.go
index 3913bf57..002070fc 100644
--- a/backend/internal/application/asset/create_asset.go
+++ b/backend/internal/application/asset/create_asset.go
@@ -25,6 +25,8 @@ type CreateAssetInput struct {
// Attributes is the raw, unvalidated bag from the client. It is only ever
// persisted after AttributeValidator has checked and coerced it.
Attributes map[string]any
+ // Source records how the asset entered the inventory. Empty means MANUAL.
+ Source string
}
// AttributeValidator validates a raw attribute bag against the tenant's schema
@@ -97,6 +99,11 @@ func (uc *CreateAssetUseCase) Execute(ctx context.Context, tenantID uuid.UUID, i
return nil, domain.NewValidationError("attributes require an asset category — pick one so the values can be validated against its schema")
}
+ source := input.Source
+ if source == "" {
+ source = "MANUAL"
+ }
+
assetEntity := &domain.Asset{
ID: uuid.New(),
TenantID: tenantID,
@@ -104,7 +111,7 @@ func (uc *CreateAssetUseCase) Execute(ctx context.Context, tenantID uuid.UUID, i
Type: input.Type,
Criticality: criticality,
Owner: input.Owner,
- Source: "MANUAL",
+ Source: source,
Category: category,
Attributes: attrs,
}
diff --git a/backend/internal/application/asset/import_assets.go b/backend/internal/application/asset/import_assets.go
new file mode 100644
index 00000000..2277f63e
--- /dev/null
+++ b/backend/internal/application/asset/import_assets.go
@@ -0,0 +1,417 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
+
+package asset
+
+import (
+ "bytes"
+ "context"
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/google/uuid"
+ "github.com/opendefender/openrisk/internal/domain"
+)
+
+// ---------------------------------------------------------------------------
+// CSV import of the asset inventory (#861).
+//
+// Same contract as the risk import (#755): every row is validated before
+// anything is written, one bad row means nothing is persisted, and every error
+// comes back with its line, column, a stable code the page translates, and an
+// English message. A valid file is written in ONE transaction through
+// CreateAssetUseCase, so an imported asset is born like one typed into the form.
+//
+// A name already in the tenant's inventory, or repeated in the file, is
+// refused: the risk import links assets by name, and re-importing the same
+// file must never double the inventory.
+// ---------------------------------------------------------------------------
+
+const (
+ // MaxImportRows caps one file, as for risks.
+ MaxImportRows = 1000
+ // MaxImportBytes caps the upload, as for risks.
+ MaxImportBytes = 2 << 20
+ // maxImportNameLen bounds a name, as the form does.
+ maxImportNameLen = 255
+)
+
+const (
+ importColName = "name"
+ importColType = "type"
+ importColCriticality = "criticality"
+ importColOwner = "owner"
+)
+
+var importColumnAliases = map[string]string{
+ "name": importColName,
+ "nom": importColName,
+ "type": importColType,
+ "criticality": importColCriticality,
+ "criticité": importColCriticality,
+ "criticite": importColCriticality,
+ "owner": importColOwner,
+}
+
+// ImportColumns is the accepted header, in template order.
+var ImportColumns = []string{importColName, importColType, importColCriticality, importColOwner}
+
+// importCriticalities maps accepted spellings, upper-cased, to the domain value.
+// French spellings are accepted because French-locale teams write them.
+var importCriticalities = map[string]domain.AssetCriticality{
+ "LOW": domain.CriticalityLow, "FAIBLE": domain.CriticalityLow,
+ "MEDIUM": domain.CriticalityMedium, "MOYENNE": domain.CriticalityMedium, "MOYEN": domain.CriticalityMedium,
+ "HIGH": domain.CriticalityHigh, "ÉLEVÉE": domain.CriticalityHigh, "ELEVEE": domain.CriticalityHigh, "ÉLEVÉ": domain.CriticalityHigh, "ELEVE": domain.CriticalityHigh,
+ "CRITICAL": domain.CriticalityCritical, "CRITIQUE": domain.CriticalityCritical,
+}
+
+// AssetTxRunner runs fn inside one database transaction with an asset
+// repository bound to it. An error from fn rolls everything back.
+type AssetTxRunner func(ctx context.Context, fn func(repo domain.AssetRepository) error) error
+
+// ExistingAssetNames lists the names of the tenant's live assets, tenant-scoped.
+type ExistingAssetNames func(ctx context.Context, tenantID uuid.UUID) ([]string, error)
+
+// ImportCapacity reports how many more assets the tenant's plan allows.
+// A negative value means unlimited.
+type ImportCapacity func(ctx context.Context, tenantID uuid.UUID) (remaining int, err error)
+
+// ImportAssetsInput is one uploaded file.
+type ImportAssetsInput struct {
+ CSV []byte
+}
+
+// ImportRowError locates one problem in the file; same wire shape as the risk
+// import's. Line is 1-based with the header on line 1; 0 means the whole file.
+type ImportRowError struct {
+ Line int `json:"line"`
+ Column string `json:"column,omitempty"`
+ Code string `json:"code"`
+ Params map[string]string `json:"params,omitempty"`
+ Message string `json:"message"`
+}
+
+// ImportAssetsResult is what the caller is told.
+type ImportAssetsResult struct {
+ Created int `json:"created"`
+ Rejected int `json:"rejected"`
+ AssetIDs []uuid.UUID `json:"asset_ids"`
+ Errors []ImportRowError `json:"errors"`
+}
+
+// ImportRejectedError carries a refused file's result; it matches
+// domain.ErrValidation.
+type ImportRejectedError struct {
+ Result *ImportAssetsResult
+}
+
+func (e *ImportRejectedError) Error() string {
+ return fmt.Sprintf("import rejected: %d error(s), nothing was imported", len(e.Result.Errors))
+}
+
+func (e *ImportRejectedError) Unwrap() error { return domain.ErrValidation }
+
+// ImportOverCapacityError means the file would take the tenant past its plan's
+// asset limit. Nothing is written.
+type ImportOverCapacityError struct {
+ Requested int
+ Remaining int
+}
+
+func (e *ImportOverCapacityError) Error() string {
+ return fmt.Sprintf("import of %d assets exceeds the plan: %d more allowed", e.Requested, e.Remaining)
+}
+
+func (e *ImportOverCapacityError) Unwrap() error { return domain.ErrForbidden }
+
+// ImportAssetsUseCase imports a CSV file into the tenant's inventory.
+type ImportAssetsUseCase struct {
+ inTx AssetTxRunner
+ existing ExistingAssetNames
+ capacity ImportCapacity
+ activation ActivationRecorder
+}
+
+// NewImportAssetsUseCase builds the use case. existing is required: without
+// it a re-import would silently duplicate the inventory.
+func NewImportAssetsUseCase(inTx AssetTxRunner, existing ExistingAssetNames) *ImportAssetsUseCase {
+ return &ImportAssetsUseCase{inTx: inTx, existing: existing}
+}
+
+// WithCapacity attaches the plan-limit check.
+func (uc *ImportAssetsUseCase) WithCapacity(c ImportCapacity) *ImportAssetsUseCase {
+ uc.capacity = c
+ return uc
+}
+
+// WithActivation attaches the activation recorder, fed after commit only.
+func (uc *ImportAssetsUseCase) WithActivation(rec ActivationRecorder) *ImportAssetsUseCase {
+ uc.activation = rec
+ return uc
+}
+
+type importAssetRow struct {
+ line int
+ input CreateAssetInput
+}
+
+// Execute validates the whole file, then creates every row in one transaction.
+func (uc *ImportAssetsUseCase) Execute(ctx context.Context, tenantID uuid.UUID, input ImportAssetsInput) (*ImportAssetsResult, error) {
+ if tenantID == uuid.Nil {
+ return nil, domain.NewUnauthorizedError("tenant is required")
+ }
+ if len(input.CSV) > MaxImportBytes {
+ return nil, domain.NewValidationError(fmt.Sprintf("file is larger than %d MB", MaxImportBytes>>20))
+ }
+
+ rows, errs := parseAssetCSV(input.CSV)
+
+ // Names already in the inventory. Checked even when other rows failed, so
+ // the user sees every problem in one pass.
+ if len(rows) > 0 {
+ names, err := uc.existing(ctx, tenantID)
+ if err != nil {
+ return nil, domain.NewInternalError(fmt.Sprintf("failed to list assets: %v", err))
+ }
+ taken := make(map[string]bool, len(names))
+ for _, n := range names {
+ taken[nameKey(n)] = true
+ }
+ for _, r := range rows {
+ if taken[nameKey(r.input.Name)] {
+ errs = append(errs, ImportRowError{Line: r.line, Column: importColName, Code: "asset_exists",
+ Params: map[string]string{"value": r.input.Name},
+ Message: fmt.Sprintf("an asset named %q is already in the inventory", r.input.Name)})
+ }
+ }
+ }
+ if len(errs) > 0 {
+ return nil, &ImportRejectedError{Result: rejected(errs)}
+ }
+
+ if uc.capacity != nil {
+ remaining, err := uc.capacity(ctx, tenantID)
+ // A counting error fails open, as the per-create middleware does.
+ if err == nil && remaining >= 0 && len(rows) > remaining {
+ return nil, &ImportOverCapacityError{Requested: len(rows), Remaining: remaining}
+ }
+ }
+
+ created := make([]*domain.Asset, 0, len(rows))
+ err := uc.inTx(ctx, func(repo domain.AssetRepository) error {
+ create := NewCreateAssetUseCase(repo)
+ for _, row := range rows {
+ a, err := create.Execute(ctx, tenantID, row.input)
+ if err != nil {
+ var appErr *domain.AppError
+ if errors.As(err, &appErr) && errors.Is(err, domain.ErrValidation) {
+ return &ImportRejectedError{Result: rejected([]ImportRowError{{Line: row.line, Code: "rejected_by_rules", Message: appErr.Message}})}
+ }
+ return err
+ }
+ created = append(created, a)
+ }
+ return nil
+ })
+ if err != nil {
+ var rej *ImportRejectedError
+ if errors.As(err, &rej) {
+ return nil, rej
+ }
+ return nil, domain.NewInternalError(fmt.Sprintf("import transaction failed: %v", err))
+ }
+
+ result := &ImportAssetsResult{
+ Created: len(created),
+ AssetIDs: make([]uuid.UUID, 0, len(created)),
+ Errors: []ImportRowError{},
+ }
+ for _, a := range created {
+ result.AssetIDs = append(result.AssetIDs, a.ID)
+ }
+ if uc.activation != nil && len(created) > 0 {
+ uc.activation.Record(ctx, tenantID, string(domain.ActivationAssetConnected), map[string]interface{}{
+ "asset_id": created[0].ID.String(),
+ "source": "IMPORT",
+ "count": len(created),
+ })
+ }
+ return result, nil
+}
+
+func nameKey(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
+
+// rejected builds the result of a refused file: nothing created, every
+// offending line counted once.
+func rejected(errs []ImportRowError) *ImportAssetsResult {
+ lines := map[int]bool{}
+ for _, e := range errs {
+ if e.Line > 1 {
+ lines[e.Line] = true
+ }
+ }
+ sort.SliceStable(errs, func(i, j int) bool { return errs[i].Line < errs[j].Line })
+ return &ImportAssetsResult{Created: 0, Rejected: len(lines), AssetIDs: []uuid.UUID{}, Errors: errs}
+}
+
+// parseAssetCSV reads and validates the file. With errors it may still return
+// the rows it could read, so their names are checked against the inventory in
+// the same pass; such a file is never written.
+func parseAssetCSV(data []byte) ([]importAssetRow, []ImportRowError) {
+ data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) // Excel's UTF-8 BOM
+ if len(bytes.TrimSpace(data)) == 0 {
+ return nil, []ImportRowError{{Code: "file_empty", Message: "the file is empty"}}
+ }
+ if !utf8.Valid(data) {
+ return nil, []ImportRowError{{Code: "not_utf8", Message: "the file is not UTF-8 text; save it as \"CSV UTF-8\""}}
+ }
+
+ firstLine, _, _ := bytes.Cut(data, []byte("\n"))
+ reader := csv.NewReader(bytes.NewReader(data))
+ if bytes.Count(firstLine, []byte(";")) > bytes.Count(firstLine, []byte(",")) {
+ reader.Comma = ';'
+ }
+ reader.FieldsPerRecord = -1
+ reader.TrimLeadingSpace = true
+
+ header, err := reader.Read()
+ if err != nil {
+ return nil, []ImportRowError{{Line: 1, Code: "header_unreadable", Params: map[string]string{"detail": err.Error()},
+ Message: fmt.Sprintf("the header cannot be read: %v", err)}}
+ }
+
+ var errs []ImportRowError
+ cols := map[string]int{}
+ for i, h := range header {
+ raw := strings.TrimSpace(h)
+ canon, ok := importColumnAliases[strings.ToLower(raw)]
+ if !ok {
+ errs = append(errs, ImportRowError{Line: 1, Column: raw, Code: "unknown_column",
+ Params: map[string]string{"column": raw, "accepted": strings.Join(ImportColumns, ", ")},
+ Message: fmt.Sprintf("unknown column %q; accepted columns are %s", raw, strings.Join(ImportColumns, ", "))})
+ continue
+ }
+ if _, dup := cols[canon]; dup {
+ errs = append(errs, ImportRowError{Line: 1, Column: raw, Code: "duplicate_column",
+ Params: map[string]string{"column": canon}, Message: fmt.Sprintf("column %q appears twice", canon)})
+ continue
+ }
+ cols[canon] = i
+ }
+ if _, ok := cols[importColName]; !ok {
+ errs = append(errs, ImportRowError{Line: 1, Column: importColName, Code: "missing_column",
+ Params: map[string]string{"column": importColName}, Message: `required column "name" is missing`})
+ }
+ if len(errs) > 0 {
+ return nil, errs
+ }
+
+ cell := func(rec []string, col string) string {
+ i, ok := cols[col]
+ if !ok || i >= len(rec) {
+ return ""
+ }
+ return strings.TrimSpace(rec[i])
+ }
+
+ var rows []importAssetRow
+ firstLineOf := map[string]int{}
+ dataRows := 0
+ for {
+ rec, err := reader.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ line := 0
+ var pe *csv.ParseError
+ if errors.As(err, &pe) {
+ line = pe.StartLine
+ }
+ errs = append(errs, ImportRowError{Line: line, Code: "line_unreadable", Params: map[string]string{"detail": err.Error()},
+ Message: fmt.Sprintf("the line cannot be read: %v", err)})
+ break // a broken quote can swallow the rest of the file
+ }
+ line, _ := reader.FieldPos(0)
+ if isBlank(rec) {
+ continue
+ }
+ dataRows++
+ if dataRows > MaxImportRows {
+ return nil, []ImportRowError{{Code: "too_many_rows", Params: map[string]string{"max": strconv.Itoa(MaxImportRows)},
+ Message: fmt.Sprintf("the file has more than %d rows; split it into several files", MaxImportRows)}}
+ }
+ if len(rec) > len(header) {
+ errs = append(errs, ImportRowError{Line: line, Code: "cell_count",
+ Params: map[string]string{"cells": strconv.Itoa(len(rec)), "header": strconv.Itoa(len(header))},
+ Message: fmt.Sprintf("the line has %d cells but the header has %d", len(rec), len(header))})
+ continue
+ }
+
+ rowOK := true
+ name := cell(rec, importColName)
+ switch {
+ case name == "":
+ errs = append(errs, ImportRowError{Line: line, Column: importColName, Code: "required", Message: "name is required"})
+ rowOK = false
+ case utf8.RuneCountInString(name) > maxImportNameLen:
+ errs = append(errs, ImportRowError{Line: line, Column: importColName, Code: "too_long",
+ Params: map[string]string{"max": strconv.Itoa(maxImportNameLen)}, Message: "name must be 255 characters or less"})
+ rowOK = false
+ default:
+ if first, dup := firstLineOf[nameKey(name)]; dup {
+ errs = append(errs, ImportRowError{Line: line, Column: importColName, Code: "duplicate_in_file",
+ Params: map[string]string{"value": name, "line": strconv.Itoa(first)},
+ Message: fmt.Sprintf("%q already appears on line %d", name, first)})
+ rowOK = false
+ } else {
+ firstLineOf[nameKey(name)] = line
+ }
+ }
+
+ var criticality domain.AssetCriticality
+ if raw := cell(rec, importColCriticality); raw != "" {
+ c, ok := importCriticalities[strings.ToUpper(raw)]
+ if !ok {
+ errs = append(errs, ImportRowError{Line: line, Column: importColCriticality, Code: "invalid_criticality",
+ Params: map[string]string{"value": raw},
+ Message: fmt.Sprintf("%q is not a criticality; use LOW, MEDIUM, HIGH or CRITICAL", raw)})
+ rowOK = false
+ }
+ criticality = c
+ }
+
+ if !rowOK {
+ continue
+ }
+ rows = append(rows, importAssetRow{line: line, input: CreateAssetInput{
+ Name: name,
+ Type: cell(rec, importColType),
+ Criticality: criticality,
+ Owner: cell(rec, importColOwner),
+ Source: "IMPORT",
+ }})
+ }
+
+ if dataRows == 0 && len(errs) == 0 {
+ return nil, []ImportRowError{{Code: "no_rows", Message: "the file has a header but no asset rows"}}
+ }
+ return rows, errs
+}
+
+func isBlank(rec []string) bool {
+ for _, v := range rec {
+ if strings.TrimSpace(v) != "" {
+ return false
+ }
+ }
+ return true
+}
diff --git a/backend/internal/application/asset/import_assets_test.go b/backend/internal/application/asset/import_assets_test.go
new file mode 100644
index 00000000..d8493dc8
--- /dev/null
+++ b/backend/internal/application/asset/import_assets_test.go
@@ -0,0 +1,172 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
+
+package asset
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/opendefender/openrisk/internal/domain"
+)
+
+// fakeAssetTx stages creates and only "commits" them when fn succeeds.
+type fakeAssetTx struct {
+ committed []*domain.Asset
+ failOn int // 1-based create call that errors; 0 never
+ existing map[uuid.UUID][]string
+}
+
+func (f *fakeAssetTx) run(_ context.Context, fn func(repo domain.AssetRepository) error) error {
+ var staged []*domain.Asset
+ calls := 0
+ repo := &MockAssetRepository{createFunc: func(_ context.Context, a *domain.Asset) error {
+ calls++
+ if calls == f.failOn {
+ return errors.New("disk full")
+ }
+ staged = append(staged, a)
+ return nil
+ }}
+ if err := fn(repo); err != nil {
+ return err
+ }
+ f.committed = append(f.committed, staged...)
+ return nil
+}
+
+func (f *fakeAssetTx) names(_ context.Context, tenantID uuid.UUID) ([]string, error) {
+ return f.existing[tenantID], nil
+}
+
+func importAssets(tx *fakeAssetTx, tenant uuid.UUID, csv string) (*ImportAssetsResult, error) {
+ return NewImportAssetsUseCase(tx.run, tx.names).Execute(context.Background(), tenant, ImportAssetsInput{CSV: []byte(csv)})
+}
+
+func assetRejected(t *testing.T, err error) []ImportRowError {
+ t.Helper()
+ var rej *ImportRejectedError
+ require.ErrorAs(t, err, &rej)
+ require.ErrorIs(t, err, domain.ErrValidation)
+ assert.Equal(t, 0, rej.Result.Created)
+ return rej.Result.Errors
+}
+
+func TestImportAssets_Success(t *testing.T) {
+ tx := &fakeAssetTx{}
+ tenant := uuid.New()
+ res, err := importAssets(tx, tenant, "name,type,criticality,owner\n"+
+ "Core banking DB,Database,critical,DSI\n"+
+ "Kiosk,Laptop,faible,\n"+
+ "Web front,Server,,\n")
+ require.NoError(t, err)
+ assert.Equal(t, 3, res.Created)
+ require.Len(t, tx.committed, 3)
+
+ db := tx.committed[0]
+ assert.Equal(t, tenant, db.TenantID)
+ assert.Equal(t, "Core banking DB", db.Name)
+ assert.Equal(t, domain.CriticalityCritical, db.Criticality)
+ assert.Equal(t, "DSI", db.Owner)
+ assert.Equal(t, "IMPORT", db.Source)
+ assert.Equal(t, domain.CriticalityLow, tx.committed[1].Criticality, "French spelling accepted")
+ assert.Equal(t, domain.CriticalityMedium, tx.committed[2].Criticality, "empty means MEDIUM")
+}
+
+// NotFound: a file with no asset rows finds nothing to import and says so.
+func TestImportAssets_NotFound(t *testing.T) {
+ tx := &fakeAssetTx{}
+ _, err := importAssets(tx, uuid.New(), "name,type\n\n")
+ errs := assetRejected(t, err)
+ assert.Equal(t, "no_rows", errs[0].Code)
+ assert.Empty(t, tx.committed)
+}
+
+func TestImportAssets_Unauthorized(t *testing.T) {
+ tx := &fakeAssetTx{}
+ _, err := importAssets(tx, uuid.Nil, "name\nA\n")
+ require.ErrorIs(t, err, domain.ErrUnauthorized)
+ assert.Empty(t, tx.committed)
+}
+
+func TestImportAssets_Validation_OneBadRowImportsNothing(t *testing.T) {
+ tx := &fakeAssetTx{}
+ _, err := importAssets(tx, uuid.New(), "name,criticality\n"+
+ "Good,HIGH\n"+
+ ",LOW\n"+
+ "Odd,urgent\n"+
+ "good,LOW\n")
+ errs := assetRejected(t, err)
+ assert.Empty(t, tx.committed, "a file with an invalid row must persist nothing")
+
+ got := map[string]string{}
+ for _, e := range errs {
+ got[fmt.Sprintf("%d:%s", e.Line, e.Column)] = e.Code
+ }
+ assert.Equal(t, map[string]string{
+ "3:name": "required",
+ "4:criticality": "invalid_criticality",
+ "5:name": "duplicate_in_file",
+ }, got)
+}
+
+func TestImportAssets_ExistingNameIsRefused_OnlyInSameTenant(t *testing.T) {
+ tenant, other := uuid.New(), uuid.New()
+ tx := &fakeAssetTx{existing: map[uuid.UUID][]string{tenant: {"Web front"}, other: {"Kiosk"}}}
+
+ _, err := importAssets(tx, tenant, "name\nweb FRONT\nKiosk\n")
+ errs := assetRejected(t, err)
+ require.Len(t, errs, 1, "another tenant's names do not count")
+ assert.Equal(t, 2, errs[0].Line)
+ assert.Equal(t, "asset_exists", errs[0].Code)
+ assert.Empty(t, tx.committed)
+}
+
+func TestImportAssets_WriteFailureRollsBackEverything(t *testing.T) {
+ tx := &fakeAssetTx{failOn: 2}
+ _, err := importAssets(tx, uuid.New(), "name\nA\nB\nC\n")
+ require.Error(t, err)
+ assert.Empty(t, tx.committed)
+}
+
+func TestImportAssets_HeaderAndFileErrors(t *testing.T) {
+ for name, tc := range map[string]struct{ csv, code string }{
+ "unknown column": {"name,colour\nA,red\n", "unknown_column"},
+ "missing name": {"type\nServer\n", "missing_column"},
+ "empty": {"", "file_empty"},
+ "not utf8": {"name\n\xff\xfe\n", "not_utf8"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ tx := &fakeAssetTx{}
+ _, err := importAssets(tx, uuid.New(), tc.csv)
+ assert.Equal(t, tc.code, assetRejected(t, err)[0].Code)
+ })
+ }
+}
+
+func TestImportAssets_FrenchSpreadsheetExport(t *testing.T) {
+ tx := &fakeAssetTx{}
+ res, err := importAssets(tx, uuid.New(), "\xef\xbb\xbfnom;type;criticité\nPoste RH;Laptop;Élevée\n")
+ require.NoError(t, err)
+ assert.Equal(t, 1, res.Created)
+ assert.Equal(t, domain.CriticalityHigh, tx.committed[0].Criticality)
+}
+
+func TestImportAssets_OverCapacityWritesNothing(t *testing.T) {
+ tx := &fakeAssetTx{}
+ _, err := NewImportAssetsUseCase(tx.run, tx.names).
+ WithCapacity(func(context.Context, uuid.UUID) (int, error) { return 1, nil }).
+ Execute(context.Background(), uuid.New(), ImportAssetsInput{CSV: []byte("name\nA\nB\n")})
+ var over *ImportOverCapacityError
+ require.ErrorAs(t, err, &over)
+ assert.Equal(t, 2, over.Requested)
+ assert.Empty(t, tx.committed)
+}
diff --git a/backend/internal/handler/asset_import_handler.go b/backend/internal/handler/asset_import_handler.go
new file mode 100644
index 00000000..243883e4
--- /dev/null
+++ b/backend/internal/handler/asset_import_handler.go
@@ -0,0 +1,90 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
+
+package handler
+
+import (
+ "errors"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+
+ assetuc "github.com/opendefender/openrisk/internal/application/asset"
+)
+
+// AssetImportHandler serves POST /assets/import (#861).
+type AssetImportHandler struct {
+ uc *assetuc.ImportAssetsUseCase
+}
+
+func NewAssetImportHandler(uc *assetuc.ImportAssetsUseCase) *AssetImportHandler {
+ return &AssetImportHandler{uc: uc}
+}
+
+// ImportAssets POST /assets/import — multipart field "file", a CSV.
+//
+// 200 {created, rejected, asset_ids, errors: []} when every row was written.
+// 422 {error, message, created: 0, rejected, errors: [{line, column, code, params, message}]}
+// when any row is invalid: nothing was written.
+// 402 limit_reached when the file would take the tenant past its plan.
+//
+// Same contract as POST /risks/import. Tenant comes from the signed session only.
+func (h *AssetImportHandler) ImportAssets(c *fiber.Ctx) error {
+ file, err := c.FormFile("file")
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "validation_failed", "message": "Attach a CSV file in the \"file\" field.",
+ })
+ }
+ if !strings.EqualFold(filepath.Ext(file.Filename), ".csv") {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "validation_failed", "message": "Only CSV files can be imported.",
+ })
+ }
+ if file.Size > assetuc.MaxImportBytes {
+ return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
+ "error": "validation_failed", "message": "The file is larger than 2 MB. Split it into several files.",
+ })
+ }
+ f, err := file.Open()
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "validation_failed", "message": "The file cannot be read."})
+ }
+ defer f.Close()
+ data, err := io.ReadAll(io.LimitReader(f, assetuc.MaxImportBytes+1))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "validation_failed", "message": "The file cannot be read."})
+ }
+
+ result, err := h.uc.Execute(c.UserContext(), tenantID(c), assetuc.ImportAssetsInput{CSV: data})
+ if err != nil {
+ var rej *assetuc.ImportRejectedError
+ if errors.As(err, &rej) {
+ return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
+ "error": "validation_failed",
+ "message": "Nothing was imported. Fix the lines below and import the file again.",
+ "created": rej.Result.Created,
+ "rejected": rej.Result.Rejected,
+ "asset_ids": rej.Result.AssetIDs,
+ "errors": rej.Result.Errors,
+ })
+ }
+ var over *assetuc.ImportOverCapacityError
+ if errors.As(err, &over) {
+ return c.Status(fiber.StatusPaymentRequired).JSON(fiber.Map{
+ "code": "limit_reached",
+ "limit_key": "assets",
+ "requested": over.Requested,
+ "remaining": over.Remaining,
+ "message": "This file would take you past your plan's asset limit. Nothing was imported.",
+ "upgrade_url": "/settings?tab=billing",
+ })
+ }
+ return writeAppError(c, err)
+ }
+ return c.JSON(result)
+}
diff --git a/backend/internal/handler/asset_import_test.go b/backend/internal/handler/asset_import_test.go
new file mode 100644
index 00000000..881531ee
--- /dev/null
+++ b/backend/internal/handler/asset_import_test.go
@@ -0,0 +1,173 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
+
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ assetuc "github.com/opendefender/openrisk/internal/application/asset"
+ "github.com/opendefender/openrisk/internal/domain"
+ "github.com/opendefender/openrisk/internal/infrastructure/repository"
+ "github.com/opendefender/openrisk/internal/middleware"
+ "github.com/opendefender/openrisk/internal/testsupport/sqliteschema"
+)
+
+// assetImportApp mounts POST /assets/import as main.go does — behind
+// assets:create — over a real sqlite database, so the transaction is real.
+type assetImportApp struct {
+ app *fiber.App
+ db *gorm.DB
+ tenant *uuid.UUID
+ perms *[]string
+}
+
+func newAssetImportApp(t *testing.T) *assetImportApp {
+ t.Helper()
+ dsn := "file:asset_import_" + uuid.New().String() + "?mode=memory&cache=private"
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.Exec(`CREATE TABLE assets (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, name TEXT NOT NULL,
+ criticality TEXT NOT NULL DEFAULT 'MEDIUM', created_at DATETIME, updated_at DATETIME, deleted_at DATETIME)`).Error)
+ require.NoError(t, sqliteschema.Reconcile(db, "assets", &domain.Asset{}))
+
+ h := &assetImportApp{db: db, tenant: new(uuid.UUID), perms: &[]string{"assets:create"}}
+ app := fiber.New()
+ app.Use(func(c *fiber.Ctx) error {
+ middleware.SetContext(c, &middleware.RequestContext{UserID: uuid.New(), OrganizationID: *h.tenant})
+ c.Locals("permissions", *h.perms)
+ return c.Next()
+ })
+ handler := NewAssetImportHandler(assetuc.NewImportAssetsUseCase(repository.RunAssetTx(db), repository.ListAssetNames(db)))
+ app.Post("/api/v1/assets/import", middleware.RequirePermission("assets:create"), handler.ImportAssets)
+ h.app = app
+ return h
+}
+
+func (h *assetImportApp) upload(t *testing.T, filename, content string) (int, map[string]any) {
+ t.Helper()
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ part, err := w.CreateFormFile("file", filename)
+ require.NoError(t, err)
+ _, _ = part.Write([]byte(content))
+ require.NoError(t, w.Close())
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/assets/import", &body)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ resp, err := h.app.Test(req, -1)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ var decoded map[string]any
+ _ = json.Unmarshal(raw, &decoded)
+ return resp.StatusCode, decoded
+}
+
+func (h *assetImportApp) assetsOf(t *testing.T, tenant uuid.UUID) []domain.Asset {
+ t.Helper()
+ var out []domain.Asset
+ require.NoError(t, h.db.Where("tenant_id = ?", tenant).Order("name").Find(&out).Error)
+ return out
+}
+
+const validAssetImport = "name,type,criticality,owner\n" +
+ "Core banking DB,Database,CRITICAL,DSI\n" +
+ "Kiosk,Laptop,low,\n"
+
+func TestAssetImportHTTP_Success(t *testing.T) {
+ h := newAssetImportApp(t)
+ tenant := uuid.New()
+ *h.tenant = tenant
+
+ status, body := h.upload(t, "inventory.csv", validAssetImport)
+ require.Equal(t, fiber.StatusOK, status, "%v", body)
+ require.EqualValues(t, 2, body["created"])
+ require.Len(t, body["asset_ids"], 2)
+
+ rows := h.assetsOf(t, tenant)
+ require.Len(t, rows, 2)
+ require.Equal(t, "Core banking DB", rows[0].Name)
+ require.Equal(t, domain.CriticalityCritical, rows[0].Criticality)
+ require.Equal(t, "IMPORT", rows[0].Source)
+ require.Equal(t, domain.CriticalityLow, rows[1].Criticality)
+
+ // Re-importing the same file never doubles the inventory.
+ status, body = h.upload(t, "inventory.csv", validAssetImport)
+ require.Equal(t, fiber.StatusUnprocessableEntity, status, "%v", body)
+ require.Len(t, h.assetsOf(t, tenant), 2)
+}
+
+func TestAssetImportHTTP_Validation_NothingPersisted(t *testing.T) {
+ h := newAssetImportApp(t)
+ tenant := uuid.New()
+ *h.tenant = tenant
+
+ status, body := h.upload(t, "inventory.csv", validAssetImport+"Printer,Device,urgent,\n")
+ require.Equal(t, fiber.StatusUnprocessableEntity, status, "%v", body)
+ require.EqualValues(t, 0, body["created"])
+ errs, _ := body["errors"].([]any)
+ require.Len(t, errs, 1)
+ first, _ := errs[0].(map[string]any)
+ require.EqualValues(t, 4, first["line"])
+ require.Equal(t, "criticality", first["column"])
+ require.Equal(t, "invalid_criticality", first["code"])
+ require.Empty(t, h.assetsOf(t, tenant), "one invalid row means zero rows persisted")
+}
+
+func TestAssetImportHTTP_Unauthorized(t *testing.T) {
+ h := newAssetImportApp(t)
+ tenant := uuid.New()
+ *h.tenant = tenant
+ *h.perms = []string{"assets:read"}
+
+ status, _ := h.upload(t, "inventory.csv", validAssetImport)
+ require.Equal(t, fiber.StatusForbidden, status)
+ require.Empty(t, h.assetsOf(t, tenant))
+}
+
+func TestAssetImportHTTP_NotFound_NoFile(t *testing.T) {
+ h := newAssetImportApp(t)
+ *h.tenant = uuid.New()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/assets/import", nil)
+ resp, err := h.app.Test(req, -1)
+ require.NoError(t, err)
+ require.Equal(t, fiber.StatusBadRequest, resp.StatusCode)
+
+ status, _ := h.upload(t, "inventory.xlsx", validAssetImport)
+ require.Equal(t, fiber.StatusBadRequest, status, "only CSV is accepted")
+}
+
+func TestAssetImportHTTP_RowsLandOnlyInCallersTenant(t *testing.T) {
+ h := newAssetImportApp(t)
+ tenantA, tenantB := uuid.New(), uuid.New()
+
+ *h.tenant = tenantB
+ status, body := h.upload(t, "b.csv", "name\nKiosk\n")
+ require.Equal(t, fiber.StatusOK, status, "%v", body)
+
+ // Tenant B already has "Kiosk"; that must not block tenant A's own.
+ *h.tenant = tenantA
+ status, body = h.upload(t, "a.csv", validAssetImport)
+ require.Equal(t, fiber.StatusOK, status, "%v", body)
+
+ require.Len(t, h.assetsOf(t, tenantA), 2)
+ bRows := h.assetsOf(t, tenantB)
+ require.Len(t, bRows, 1, "tenant A's import must not land in tenant B")
+ require.Equal(t, "Kiosk", bRows[0].Name)
+}
diff --git a/backend/internal/infrastructure/repository/gorm_asset_repository.go b/backend/internal/infrastructure/repository/gorm_asset_repository.go
index a47dfc3d..c9cd8e3d 100644
--- a/backend/internal/infrastructure/repository/gorm_asset_repository.go
+++ b/backend/internal/infrastructure/repository/gorm_asset_repository.go
@@ -38,6 +38,33 @@ func (r *GormAssetRepository) Create(ctx context.Context, asset *domain.Asset) e
return r.db.WithContext(ctx).Create(asset).Error
}
+// RunAssetTx runs fn inside one transaction with an asset repository bound to
+// it, for the CSV import that writes every asset or none (#861). It satisfies
+// application/asset.AssetTxRunner.
+func RunAssetTx(db *gorm.DB) func(ctx context.Context, fn func(repo domain.AssetRepository) error) error {
+ return func(ctx context.Context, fn func(repo domain.AssetRepository) error) error {
+ return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ return fn(NewGormAssetRepository(tx))
+ })
+ }
+}
+
+// ListAssetNames lists the names of a tenant's live assets, so the import can
+// refuse a name the inventory already holds (#861). It satisfies
+// application/asset.ExistingAssetNames.
+func ListAssetNames(db *gorm.DB) func(ctx context.Context, tenantID uuid.UUID) ([]string, error) {
+ return func(ctx context.Context, tenantID uuid.UUID) ([]string, error) {
+ if tenantID == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id is required")
+ }
+ var names []string
+ err := db.WithContext(ctx).Model(&domain.Asset{}).
+ Where("tenant_id = ?", tenantID).
+ Pluck("name", &names).Error
+ return names, err
+ }
+}
+
// GetByID retrieves an asset by ID scoped to a tenant, with linked risks preloaded.
func (r *GormAssetRepository) GetByID(ctx context.Context, id uuid.UUID, tenantID uuid.UUID) (*domain.Asset, error) {
var asset domain.Asset
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 54d356ae..48dea259 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -146,6 +146,9 @@ const RemediationPage = lazy(() =>
const InventoryPage = lazy(() =>
import('./features/assets/InventoryPage').then((m) => ({ default: m.InventoryPage })),
);
+const ImportAssetsPage = lazy(() =>
+ import('./features/assets/ImportAssetsPage').then((m) => ({ default: m.ImportAssetsPage })),
+);
const QuestionnaireTemplatesPage = lazy(() =>
import('./features/tprm/QuestionnaireTemplatesPage').then((m) => ({
default: m.QuestionnaireTemplatesPage,
@@ -702,6 +705,7 @@ function App() {
{/* ---------------- Assets ---------------- */}
} />
+ } />
{/* The Asset Universe was superseded by the topology view (same graph,
plus zoom/pan, zone clustering, typed edges, compromise chain and
export). Kept as a redirect so existing links and bookmarks land
diff --git a/frontend/src/features/assets/ImportAssetsPage.tsx b/frontend/src/features/assets/ImportAssetsPage.tsx
new file mode 100644
index 00000000..1a79cf01
--- /dev/null
+++ b/frontend/src/features/assets/ImportAssetsPage.tsx
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
+
+// CSV import of the asset inventory (#861). The page is the shared
+// CsvImportPage, with the same all-or-nothing contract as the risk import.
+
+import { useQueryClient } from '@tanstack/react-query';
+
+import { useI18n } from '../../hooks/useI18n';
+import { CsvImportPage, type CsvImportConfig } from '../../shared/csvImport/CsvImportPage';
+import { ASSETS_QUERY_KEY } from './useAssets';
+import { ASSET_IMPORT_TEMPLATE } from './importAssetsTemplate';
+
+export const ImportAssetsPage = () => {
+ const { t } = useI18n();
+ const queryClient = useQueryClient();
+
+ const config: CsvImportConfig = {
+ endpoint: '/assets/import',
+ back: { to: '/assets', label: t('csvImport.assets.back') },
+ title: t('csvImport.assets.title'),
+ intro: t('csvImport.assets.intro'),
+ columns: [
+ { name: 'name', help: t('csvImport.assets.colName') },
+ { name: 'criticality', help: t('csvImport.assets.colCriticality') },
+ { name: 'type, owner', help: t('csvImport.assets.colOptional') },
+ ],
+ note: t('csvImport.assets.note'),
+ template: ASSET_IMPORT_TEMPLATE,
+ templateFilename: 'openrisk-assets-template.csv',
+ open: { to: '/assets', label: t('csvImport.assets.open') },
+ created: (count) => t('csvImport.assets.created', { count }),
+ emptyFile: t('csvImport.assets.emptyFile'),
+ forbidden: t('csvImport.assets.forbidden'),
+ limitTitle: t('csvImport.assets.limitTitle'),
+ limitDetail: (requested, remaining) =>
+ t('csvImport.assets.limitDetail', { requested, remaining }),
+ onCreated: () => void queryClient.invalidateQueries({ queryKey: ASSETS_QUERY_KEY }),
+ };
+
+ return ;
+};
+
+export default ImportAssetsPage;
diff --git a/frontend/src/features/assets/InventoryPage.tsx b/frontend/src/features/assets/InventoryPage.tsx
index 0bc62157..130c10ca 100644
--- a/frontend/src/features/assets/InventoryPage.tsx
+++ b/frontend/src/features/assets/InventoryPage.tsx
@@ -25,6 +25,7 @@ import {
History,
Pencil,
Trash2,
+ Upload,
type LucideIcon,
} from 'lucide-react';
import { PageFrame, PageHeader, Btn, CritBadge, EmptyState } from '../../shared/ui';
@@ -364,6 +365,13 @@ export function InventoryPage() {
icon={Atom}
onClick={() => navigate('/assets/topology')}
/>
+ {canCreate && (
+ navigate('/assets/import')}
+ />
+ )}
{canCreate && (
({
+ api: { post: (...a: unknown[]) => post(...a), defaults: { baseURL: '' } },
+}));
+vi.mock('../../../hooks/useToast', () => ({
+ useToast: () => ({ success: toastSuccess, error: vi.fn(), promise: vi.fn() }),
+}));
+
+import { ImportAssetsPage } from '../ImportAssetsPage';
+import { useUIStore } from '../../../store/uiStore';
+
+function httpError(status: number, data: unknown): AxiosError {
+ const response = {
+ status,
+ data,
+ statusText: '',
+ headers: {},
+ config: { headers: new AxiosHeaders() },
+ } as AxiosResponse;
+ return new AxiosError('request failed', String(status), undefined, undefined, response);
+}
+
+async function chooseAndImport() {
+ const user = userEvent.setup();
+ const client = new QueryClient();
+ const invalidate = vi.spyOn(client, 'invalidateQueries');
+ render(
+
+
+
+
+ ,
+ );
+ await user.upload(
+ screen.getByTestId('import-file-input'),
+ new File(['name\nA\n'], 'inventory.csv', { type: 'text/csv' }),
+ );
+ await user.click(screen.getByRole('button', { name: /^(import|importer)$/i }));
+ return invalidate;
+}
+
+describe('ImportAssetsPage', () => {
+ beforeEach(() => {
+ useUIStore.getState().setLang('en');
+ post.mockReset();
+ toastSuccess.mockReset();
+ });
+
+ it('posts to /assets/import, shows the count and refreshes the inventory', async () => {
+ post.mockResolvedValue({
+ data: { created: 2, rejected: 0, asset_ids: ['a', 'b'], errors: [] },
+ });
+ const invalidate = await chooseAndImport();
+
+ expect(await screen.findByText('2 asset(s) imported.')).toBeInTheDocument();
+ expect(post.mock.calls[0]?.[0]).toBe('/assets/import');
+ expect(toastSuccess).toHaveBeenCalledTimes(1);
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['assets'] });
+ });
+
+ it('lists asset-specific errors in French and shows no success', async () => {
+ useUIStore.getState().setLang('fr');
+ post.mockRejectedValue(
+ httpError(422, {
+ created: 0,
+ rejected: 2,
+ errors: [
+ {
+ line: 2,
+ column: 'name',
+ code: 'asset_exists',
+ params: { value: 'Kiosk' },
+ message: 'an asset named "Kiosk" is already in the inventory',
+ },
+ {
+ line: 3,
+ column: 'criticality',
+ code: 'invalid_criticality',
+ params: { value: 'urgent' },
+ message: '"urgent" is not a criticality',
+ },
+ ],
+ }),
+ );
+ await chooseAndImport();
+
+ const panel = await screen.findByTestId('import-outcome-error');
+ expect(panel).toHaveTextContent('Un actif « Kiosk » existe déjà dans l’inventaire.');
+ expect(panel).toHaveTextContent('« urgent » n’est pas une criticité');
+ expect(toastSuccess).not.toHaveBeenCalled();
+ });
+
+ it('explains the plan’s asset limit', async () => {
+ post.mockRejectedValue(httpError(402, { code: 'limit_reached', requested: 40, remaining: 5 }));
+ await chooseAndImport();
+
+ const panel = await screen.findByTestId('import-outcome-error');
+ expect(panel).toHaveTextContent('The file has 40 asset(s); your plan allows 5 more.');
+ });
+});
diff --git a/frontend/src/features/assets/importAssetsTemplate.ts b/frontend/src/features/assets/importAssetsTemplate.ts
new file mode 100644
index 00000000..7f0f61af
--- /dev/null
+++ b/frontend/src/features/assets/importAssetsTemplate.ts
@@ -0,0 +1,12 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
+
+/** The current template. Names must be new to the inventory. */
+export const ASSET_IMPORT_TEMPLATE = [
+ 'name,type,criticality,owner',
+ '"Core banking database",Database,CRITICAL,"IT department"',
+ '"Customer web portal",Server,HIGH,',
+ '"HR laptop fleet",Laptop,MEDIUM,"HR"',
+].join('\n');
diff --git a/frontend/src/features/assets/useAssets.ts b/frontend/src/features/assets/useAssets.ts
index ea61a464..9211e649 100644
--- a/frontend/src/features/assets/useAssets.ts
+++ b/frontend/src/features/assets/useAssets.ts
@@ -8,7 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { assetService, type AssetSearchFilter } from '../../services/assetService';
import type { Asset, CreateAssetInput, UpdateAssetInput } from '../../types/asset';
-const ASSETS_QUERY_KEY = ['assets'];
+export const ASSETS_QUERY_KEY = ['assets'];
const historyQueryKey = (assetId: string) => ['assets', assetId, 'history'];
/**
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 67c6a15d..e8d543db 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -953,7 +953,10 @@
"unknown_asset": "No asset named \"{value}\" in the inventory.",
"ambiguous_asset": "{count} assets are named \"{value}\": use its id instead.",
"assets_unavailable": "Linking assets is not available on this server: remove the assets column.",
- "legacy_scale": "This file uses the old 1–5 scale. OpenRisk expects probability between 0 and 1 and impact between 0 and 10. Download the current template and convert the values (probability 3/5 → 0.6, impact 4/5 → 8)."
+ "legacy_scale": "This file uses the old 1–5 scale. OpenRisk expects probability between 0 and 1 and impact between 0 and 10. Download the current template and convert the values (probability 3/5 → 0.6, impact 4/5 → 8).",
+ "asset_exists": "An asset named \"{value}\" is already in the inventory.",
+ "duplicate_in_file": "\"{value}\" already appears on line {line}.",
+ "invalid_criticality": "\"{value}\" is not a criticality: use LOW, MEDIUM, HIGH or CRITICAL."
},
"risks": {
"back": "Risk register",
@@ -971,6 +974,21 @@
"forbidden": "You are not allowed to create risks.",
"limitTitle": "Nothing was imported: this file exceeds your plan’s risk limit.",
"limitDetail": "The file has {requested} risk(s); your plan allows {remaining} more."
+ },
+ "assets": {
+ "back": "Inventory",
+ "title": "Import assets",
+ "intro": "A CSV file, one row per asset. Every row is checked before anything is imported: if a single row is invalid, nothing is imported and every error is listed.",
+ "colName": "Required, at most 255 characters, not already in the inventory and unique in the file.",
+ "colCriticality": "Optional: LOW, MEDIUM, HIGH or CRITICAL. MEDIUM when empty.",
+ "colOptional": "Optional, free text.",
+ "note": "Excel exports using \";\" are accepted. Typed attributes are filled in afterwards in the inventory. Once imported, the risk file can name these assets in its assets column.",
+ "open": "Open the inventory",
+ "created": "{count} asset(s) imported",
+ "emptyFile": "The file contained no assets.",
+ "forbidden": "You are not allowed to add assets.",
+ "limitTitle": "Nothing was imported: this file exceeds your plan’s asset limit.",
+ "limitDetail": "The file has {requested} asset(s); your plan allows {remaining} more."
}
}
}
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index bc206e7e..1c3d27de 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -953,7 +953,10 @@
"unknown_asset": "Aucun actif « {value} » dans l’inventaire.",
"ambiguous_asset": "{count} actifs s’appellent « {value} » : indiquez son identifiant.",
"assets_unavailable": "La liaison aux actifs n’est pas disponible sur ce serveur : retirez la colonne assets.",
- "legacy_scale": "Ce fichier utilise l’ancienne échelle 1–5. OpenRisk attend une probabilité entre 0 et 1 et un impact entre 0 et 10. Téléchargez le modèle actuel et convertissez les valeurs (probabilité 3/5 → 0,6 ; impact 4/5 → 8)."
+ "legacy_scale": "Ce fichier utilise l’ancienne échelle 1–5. OpenRisk attend une probabilité entre 0 et 1 et un impact entre 0 et 10. Téléchargez le modèle actuel et convertissez les valeurs (probabilité 3/5 → 0,6 ; impact 4/5 → 8).",
+ "asset_exists": "Un actif « {value} » existe déjà dans l’inventaire.",
+ "duplicate_in_file": "« {value} » figure déjà ligne {line}.",
+ "invalid_criticality": "« {value} » n’est pas une criticité : LOW, MEDIUM, HIGH ou CRITICAL (ou FAIBLE, MOYENNE, ÉLEVÉE, CRITIQUE)."
},
"risks": {
"back": "Registre des risques",
@@ -971,6 +974,21 @@
"forbidden": "Vous n’avez pas le droit de créer des risques.",
"limitTitle": "Rien n’a été importé : ce fichier dépasse la limite de risques de votre plan.",
"limitDetail": "Le fichier contient {requested} risque(s) ; votre plan en permet encore {remaining}."
+ },
+ "assets": {
+ "back": "Inventaire",
+ "title": "Importer des actifs",
+ "intro": "Un fichier CSV, une ligne par actif. Toutes les lignes sont vérifiées avant l’import : si une seule est invalide, rien n’est importé et chaque erreur vous est indiquée.",
+ "colName": "Obligatoire, 255 caractères au plus, absent de l’inventaire et unique dans le fichier.",
+ "colCriticality": "Facultatif : LOW, MEDIUM, HIGH ou CRITICAL (ou FAIBLE, MOYENNE, ÉLEVÉE, CRITIQUE). MEDIUM par défaut.",
+ "colOptional": "Facultatifs, texte libre.",
+ "note": "Les exports Excel en « ; » sont acceptés. Les attributs typés se renseignent ensuite dans l’inventaire. Une fois les actifs importés, le fichier de risques peut les citer dans sa colonne assets.",
+ "open": "Voir l’inventaire",
+ "created": "{count} actif(s) importé(s)",
+ "emptyFile": "Le fichier ne contenait aucun actif.",
+ "forbidden": "Vous n’avez pas le droit d’ajouter des actifs.",
+ "limitTitle": "Rien n’a été importé : ce fichier dépasse la limite d’actifs de votre plan.",
+ "limitDetail": "Le fichier contient {requested} actif(s) ; votre plan en permet encore {remaining}."
}
}
}
diff --git a/frontend/src/shared/csvImport/csvImportSchema.ts b/frontend/src/shared/csvImport/csvImportSchema.ts
index 7a8b8725..39e91164 100644
--- a/frontend/src/shared/csvImport/csvImportSchema.ts
+++ b/frontend/src/shared/csvImport/csvImportSchema.ts
@@ -3,7 +3,8 @@
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).
//
-// Contract of the CSV imports, starting with POST /risks/import (#755). The server is the authority on
+// Contract of the CSV imports: POST /risks/import (#755) and POST
+// /assets/import (#861) answer the same shapes. The server is the authority on
// every row; the client only refuses what it can know without reading the file,
// and parses every response so the page never shows a number it was not sent.
@@ -54,6 +55,9 @@ const KNOWN_CODES = new Set([
'ambiguous_asset',
'assets_unavailable',
'legacy_scale',
+ 'asset_exists',
+ 'duplicate_in_file',
+ 'invalid_criticality',
]);
/**