Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/OpenNSW/core/artifact/loaders/github"
"github.com/OpenNSW/core/artifact/loaders/local"
"github.com/OpenNSW/core/artifact/loaders/s3"
"github.com/OpenNSW/core/refid"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -45,6 +46,11 @@ type Config struct {
// officer may see, based on their own users.custom_data. Empty means no
// rules are configured for this deployment, so scoping is a no-op.
DataScopeRulesPath string
// RefIDGen declares the reference ID formats this deployment can issue
// (see github.com/OpenNSW/core/refid, and internal/taskconfig's refid
// block for how a task opts in). Empty means no format is configured, so
// no task can generate one.
RefIDGen refid.Config
// Environment designates the deployment environment. It exists solely to
// gate the insecure-TLS/sslmode escape hatches (see isDevEnvironment) —
// unset or any value other than "development" is treated as production.
Expand Down Expand Up @@ -73,6 +79,7 @@ type yamlConfig struct {
NSW nswclient.Config `yaml:"nsw"`
Authn authn.Config `yaml:"authn"`
Web web.Config `yaml:"web"`
RefIDGen refid.Config `yaml:"refIDGen"`
}

// yamlArtifactLoaderConfig mirrors loaders.Config's shape (Type plus one
Expand Down Expand Up @@ -236,6 +243,7 @@ func LoadConfig() (Config, error) {
ConsignmentCustomDataSchemaPath: raw.ConsignmentCustomDataSchemaPath,
DataScopeRulesPath: raw.DataScopeRulesPath,
Environment: raw.Environment,
RefIDGen: raw.RefIDGen,
DB: db,
ArtifactLoader: artifactLoader.toLoadersConfig(),
AllowedOrigins: allowedOrigins,
Expand Down
59 changes: 59 additions & 0 deletions backend/cmd/server/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,62 @@ func fieldNameSet(v any) map[string]bool {
}
return set
}

func TestLoadConfig_RefIDGen_Omitted(t *testing.T) {
writeConfig(t, buildConfig(t, "", "", "", ""))

cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
// No section means the feature is simply off — not an error.
if len(cfg.RefIDGen.Issuers) != 0 {
t.Errorf("RefIDGen.Issuers = %d, want 0 when no refIDGen section is present", len(cfg.RefIDGen.Issuers))
}
}

func TestLoadConfig_RefIDGen_Decoded(t *testing.T) {
writeConfig(t, buildConfig(t, "", "", "", `refIDGen:
issuers:
- issuer: NPQS
formats:
- idType: application_id
segments:
- {type: literal, value: "NPQS/"}
- {type: list, list: office_location, param: officeCode}
- {type: sequence, scopeKey: "{issuer}:{idType}:{officeCode}:{yyyyMMdd}", padding: 6}
lists:
office_location: [NPQS-KAT, SEA-CMB]
`))

cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}

if len(cfg.RefIDGen.Issuers) != 1 {
t.Fatalf("RefIDGen.Issuers = %d, want 1", len(cfg.RefIDGen.Issuers))
}
issuer := cfg.RefIDGen.Issuers[0]
if issuer.Issuer != "NPQS" {
t.Errorf("issuer = %q, want \"NPQS\"", issuer.Issuer)
}
if len(issuer.Formats) != 1 || issuer.Formats[0].IDType != "application_id" {
t.Fatalf("formats = %+v, want one application_id format", issuer.Formats)
}
segments := issuer.Formats[0].Segments
if len(segments) != 3 {
t.Fatalf("segments = %d, want 3", len(segments))
}
// The camelCase yaml tags are the reason refid.Config can be inlined
// rather than needing a mirror struct — check the ones that would break.
if segments[2].ScopeKey != "{issuer}:{idType}:{officeCode}:{yyyyMMdd}" {
t.Errorf("scopeKey = %q, want the configured template", segments[2].ScopeKey)
}
if segments[2].Padding != 6 {
t.Errorf("padding = %d, want 6", segments[2].Padding)
}
if got := cfg.RefIDGen.Lists["office_location"]; len(got) != 2 || got[0] != "NPQS-KAT" {
t.Errorf("lists[office_location] = %v, want [NPQS-KAT SEA-CMB]", got)
}
}
24 changes: 23 additions & 1 deletion backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import (
"github.com/OpenNSW/agency/backend/internal/logging"
"github.com/OpenNSW/agency/backend/internal/nswclient"
"github.com/OpenNSW/agency/backend/internal/rbac"
"github.com/OpenNSW/agency/backend/internal/refidstore"
"github.com/OpenNSW/agency/backend/internal/scopes"
"github.com/OpenNSW/agency/backend/internal/storage"
"github.com/OpenNSW/agency/backend/internal/user"
"github.com/OpenNSW/agency/backend/internal/web"
"github.com/OpenNSW/core/artifact"
"github.com/OpenNSW/core/artifact/loaders"
"github.com/OpenNSW/core/authz"
"github.com/OpenNSW/core/refid"
"github.com/OpenNSW/core/trace"
)

Expand Down Expand Up @@ -159,8 +161,28 @@ func main() {
consignmentService := consignment.NewService(consignmentStore, nswClient, dataScopeResolver)
consignmentHandler := consignment.NewHandler(consignmentService)

// Reference ID generation is optional per deployment: with no refIDGen
// section there is nothing to build, so skip the counter store and the
// registry entirely and hand the service a disabled one. NewRegistry
// validates every configured format up front, so a malformed section fails
// the boot rather than the first inject that needs it.
refIDs := refidstore.Disabled()
if n := len(cfg.RefIDGen.Issuers); n > 0 {
refIDSequences, err := refidstore.New(store.DB())
if err != nil {
log.Fatalf("failed to create refid sequence store: %v", err)
}
refIDs, err = refid.NewRegistry(cfg.RefIDGen, refIDSequences)
if err != nil {
log.Fatalf("invalid refIDGen config: %v", err)
}
slog.Info("reference ID generation configured", "issuers", n)
} else {
slog.Info("reference ID generation not configured; tasks declaring a refid block will fail at inject")
}

// Initialize Agency service
service := application.NewService(store, artifactRegistry, nswClient, roleService, consignmentService, dataScopeResolver)
service := application.NewService(store, artifactRegistry, nswClient, roleService, consignmentService, dataScopeResolver, refIDs)
defer func() {
if err := service.Close(); err != nil {
slog.Error("failed to close service", "error", err)
Expand Down
49 changes: 49 additions & 0 deletions backend/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,52 @@ nsw:
# DEV-ONLY: skip TLS verification for the NSW token endpoint. Keep false in
# production; only honored when environment: development.
tokenInsecureSkipVerify: false

# Reference ID generation (github.com/OpenNSW/core/refid) — OPTIONAL.
#
# Declares the reference ID formats this deployment can issue. Omit the whole
# section and the feature is simply off: no task can generate a reference ID.
# A task opts in via the `refid` block in its own task config, naming an
# (issuer, idType) from here plus the path to store the result at — see
# docs/task-config-reference.md. A task naming a format that isn't configured
# here fails its inject rather than quietly skipping.
#
# Each format is an ordered list of segments, concatenated at generation time:
# literal fixed text
# list a caller-supplied value, validated against a named list below
# date the current UTC time, using a Go reference-date layout
# sequence a durable zero-padded counter
#
# A sequence's scopeKey decides both what the counter is scoped to and how
# often it resets. Placeholders: {issuer}, {idType}, {yyyy}, {yyyyMM},
# {yyyyMMdd}, and any param name the task config supplies. Including
# {yyyyMMdd} resets daily; omitting every date placeholder never resets.
#
# Counters live in the refid_sequences table — run `migrate up` before
# starting a server with this configured.
#refIDGen:
# issuers:
# - issuer: "<AGENCY>"
# formats:
# # Produces <AGENCY>/COL/20260904/000001 — per office, reset daily.
# - idType: application_id
# segments:
# - type: literal
# value: "<AGENCY>/"
# - type: list
# list: office_location
# param: officeCode # the task config maps this to a JSON Pointer
# - type: literal
# value: "/"
# - type: date
# layout: "20060102" # Go reference date for YYYYMMDD
# - type: literal
# value: "/"
# - type: sequence
# scopeKey: "{issuer}:{idType}:{officeCode}:{yyyyMMdd}"
# padding: 6 # 000001 … 999999, then generation fails
# lists:
# # A list segment rejects any value not in its set, which fails the
# # inject — keep these in step with whatever the injected data actually
# # sends.
# office_location: [COL, GAL, KAN]
74 changes: 68 additions & 6 deletions backend/docs/task-config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ this doc focuses on the struct itself, field by field, including

```go
type TaskConfig struct {
TaskCode string `json:"taskCode"`
Meta TaskMeta `json:"meta"`
Forms TaskForms `json:"forms"`
Behavior TaskBehavior `json:"behavior"`
Permissions []Permission `json:"permissions,omitempty"`
Certificate *TaskCertificate `json:"certificate,omitempty"`
SchemaVersion int `json:"schemaVersion"`
TaskCode string `json:"taskCode"`
Meta TaskMeta `json:"meta"`
Forms TaskForms `json:"forms"`
Behavior TaskBehavior `json:"behavior"`
Permissions []Permission `json:"permissions,omitempty"`
Certificate *TaskCertificate `json:"certificate,omitempty"`
ConsignmentFields []ConsignmentField `json:"consignmentFields,omitempty"`
RefID *TaskRefID `json:"refid,omitempty"`
}
```

Expand Down Expand Up @@ -397,6 +400,65 @@ will reject the request for that application.

---

## `refid` (`*TaskRefID`, optional — nil-able)

Declares that this task's applications get an agency-issued reference ID,
generated by [`github.com/OpenNSW/core/refid`](https://github.com/OpenNSW/core/tree/main/refid)
when the application is first injected and written into the reviewer response.
Omit it entirely for tasks that need no reference number.

```go
type TaskRefID struct {
Issuer string `json:"issuer"`
IDType string `json:"idType"`
Path string `json:"path"`
Params map[string]string `json:"params,omitempty"`
}
```

```json
"refid": {
"issuer": "NPQS",
"idType": "application_id",
"path": "/reference_number",
"params": { "officeCode": "/nppo_office_location" }
}
```

| Field | Meaning |
| --- | --- |
| `issuer`, `idType` | Which configured format to generate. Both required. |
| `path` | JSON Pointer into the reviewer response where the ID is written. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should tightly couple the regid generation with the jsonforms structure. These should be decoupled, where the refid generation writes to our global state, and the form picks it up from there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewer_response is the task's global state rather than a form structure: applications has only two task-scoped JSON documents, data (injected from NSW) and reviewer_response. There is no third store to write into.

data is not a candidate either. It is replaced wholesale on every inject — set from req.Data with no carry-forward, and rewritten by the feedback-resubmission path — so anything we wrote there would be gone on the next one. And a reference number for the NSW-injected submission is NSW's to issue on its own side with its own config; refid is a shared library precisely so it can.

So path targets the only slot that fits, and the form reads it from there.

| `params` | The format's inputs, each a JSON Pointer into this task's **injected data**. |

**The format itself lives in the deployment's config**, not here — `refIDGen`
in `config.yaml` (see [`config.example.yaml`](../config.example.yaml)) declares
the issuers, their segments and any controlled lists. This split is deliberate:
which numbers an agency issues is a deployment decision, while which tasks get
one is a task decision. A task naming an `(issuer, idType)` the deployment
hasn't configured **fails the inject** rather than quietly skipping — an
application with no reference ID is not something to discover later.

`params` is what lets one task config serve every office: `officeCode` is read
off the incoming application rather than fixed, so a per-office counter needs
no config per office. `refid` ignores params a format doesn't consume, so they
can be declared generously; a param the format *does* require but which cannot
be resolved from the injected data fails the inject as a `400`.

**Generated exactly once, on first inject.** Re-injecting an existing
application keeps the number it already has, and a trader resubmitting after a
feedback request keeps it too.

**The review form needs a control at `path`** or the officer never sees the
number — `path` targets the same document `forms.review` binds to, surfaced by
the API as `agencyActionData`. A review form whose schema sets
`"additionalProperties": false` without declaring the field would reject the
officer's submission outright.

Marking that control read-only is the form author's call, and is a client-side
convention only: review submissions aren't validated server-side, so it doesn't
prevent the value being changed.

## Migration checklist for existing task configs

`permissions` (non-empty, must collectively grant `VIEW`/`REVIEW`, no actions
Expand Down
3 changes: 1 addition & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/OpenNSW/core/authn v0.3.0
github.com/OpenNSW/core/authz v0.1.0
github.com/OpenNSW/core/httputil v0.1.0
github.com/OpenNSW/core/refid v0.1.0
github.com/OpenNSW/core/secret v0.2.0
github.com/OpenNSW/core/trace v0.2.0
github.com/glebarez/sqlite v1.11.0
Expand Down Expand Up @@ -48,12 +49,10 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
Expand Down
3 changes: 2 additions & 1 deletion backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ github.com/OpenNSW/core/authz v0.1.0 h1:qWM+NObLNEaG6GZ2ZnKYmtkwrtlAmEzIljfpiQw2
github.com/OpenNSW/core/authz v0.1.0/go.mod h1:/LbQLPZtCGJ4DICQvLea8XFtSCe2M3xZCO+0SxTYsog=
github.com/OpenNSW/core/httputil v0.1.0 h1:IEkr9sHOh065+pubjlPShqmW3X2GPB9+wlHoeNDsGiU=
github.com/OpenNSW/core/httputil v0.1.0/go.mod h1:kZyjhx9ckd54vYxH/R65KW++X2Lrvt1N13ZvhMupk7M=
github.com/OpenNSW/core/refid v0.1.0 h1:r9sKtojHPRc17+VLIlBxlKPIyzTcMvpxNdaybLXClV8=
github.com/OpenNSW/core/refid v0.1.0/go.mod h1:XAsH8UTKJU4Zu6d9WcqBUP21xqmi8afqxAOH9T2pph4=
github.com/OpenNSW/core/secret v0.2.0 h1:7kxJYbVNJhN9X9/YyTUci4bv9oYKcm0nT/wBVrxBChQ=
github.com/OpenNSW/core/secret v0.2.0/go.mod h1:MEkX9vnlPRVTn+JQQsb192PoY6p3hbFSOIV+FxIMrc0=
github.com/OpenNSW/core/shared v0.3.0 h1:HcWcXyMdwzMfsVvo1HG2OTBYaQTRipTpLI0AFx/5Wgs=
Expand Down Expand Up @@ -48,7 +50,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8s
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
Expand Down
43 changes: 43 additions & 0 deletions backend/internal/application/refid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package application

import (
"context"
"errors"
"fmt"

"github.com/OpenNSW/agency/backend/internal/taskconfig"
"github.com/OpenNSW/agency/backend/pkg/jsonpointer"
"github.com/OpenNSW/core/refid"
)

// generateRefID mints this task's reference ID.
//
// A params pointer that doesn't resolve to a string is skipped rather than
// rejected: refid ignores params the configured format doesn't consume, so a
// task may declare more than any one format needs. Whether an absent value
// matters is refid's call — it returns ErrInvalidParam for a param a segment
// requires, and for an unresolved scope-key placeholder.
//
// ErrInvalidParam maps to a 400; every other failure stays unwrapped and
// surfaces as a 500.
func generateRefID(ctx context.Context, reg refid.Registry, cfg *taskconfig.TaskRefID, data map[string]any) (string, error) {
params := make(map[string]string, len(cfg.Params))
for param, pointer := range cfg.Params {
value, ok := jsonpointer.Get(data, pointer)
if !ok {
continue
}
if str, ok := value.(string); ok {
params[param] = str
}
}

id, err := reg.Generate(ctx, cfg.Issuer, cfg.IDType, params)
if err != nil {
if errors.Is(err, refid.ErrInvalidParam) {
return "", fmt.Errorf("%w: %v", ErrInvalidInjectRequest, err)
}
return "", fmt.Errorf("failed to generate reference ID for issuer %q idType %q: %w", cfg.Issuer, cfg.IDType, err)
}
return id, nil
}
Loading
Loading