fix(api): entity endpoints accept identifier form + int64 id consistency#174
Conversation
#172, #173) Task/incident/corrective-action/audit-finding/legal endpoints parsed :id with raw strconv.Atoi, so only a numeric id worked and the identifier form (TASK-6, …) 400'd. Route them through parseID (extended to strip every NextIdentifier prefix incl. legacy AST-), and standardize those entities' id to int64 to match suppliers/ assets/risks/systems — so parseID drops in and the whole API is on one id vocabulary. Also fixes the AST-/ASSET- split: the suggestion-apply path now uses the shared nextIdentifierTx(…, "asset") helper (ASSET-) instead of a hardcoded AST-. Regression test asserts identifier and numeric URLs resolve the same record for tasks/incidents/CAs/legal (get/put/delete). go build + go unit tests green. Closes #172. Closes #173.
…174) The identifier-URL tests failed: parseID stripped the prefix and used the numeric SUFFIX as the primary key, but for task/incident/corrective-action/legal the suffix is a per-org identifier_sequences value, not the id — they diverge (e.g. after rolled-back inserts), so GET /tasks/TASK-8 looked up id=8 and 404'd. Adds resolveTaskID/resolveIncidentID/resolveCorrectiveActionID/resolveLegalID that pass a numeric param straight through and look up the PREFIX-<seq> form via GetXByIdentifier (added GetTaskByIdentifier; the others already existed). Findings keep parseID — FIND-<id> uses the id itself, so the suffix IS the key. parseID trimmed back to the prefixes whose suffix equals the id (FIND- + the pre-existing supplier/asset/risk/system set). Note: the same latent bug exists in the pre-existing supplier/asset/risk/system parseID handlers (seq != id) — out of scope here, separate issue.
|
Heads-up on a real bug the identifier-URL tests caught (thanks to the regression test): Fixed by resolving the identifier form via Note for a separate ticket: the pre-existing
|
unidoc-alip
left a comment
There was a problem hiding this comment.
Solid fix, and the follow-up commit shows good judgment — the author's own regression test caught that parseID treats the numeric suffix as the PK, which only holds when the per-org identifier sequence happens to equal the id; task/incident/CA/legal now correctly resolve via GetXByIdentifier lookups instead. go build is clean and the new test file covers GET/PUT/DELETE for both id forms. Two non-blocking gaps below: a status-code inconsistency in the new resolve*ID helpers, and a pre-existing instance of the same bug class left un-flagged in the suggestion-apply path.
1. [should-fix] internal/isms/api/server.go line 319
resolveTaskID (and its incident/CA/legal siblings) return the raw GetXByIdentifier error for a syntactically valid but nonexistent identifier (e.g. GET /tasks/TASK-9999), and every call site maps that straight to 400 invalid task id. Compare with the numeric path: a nonexistent numeric id sails through this check and correctly 404s at the subsequent GetTask call (api_collab.go:2155-2157). So GET /tasks/999999 → 404 but GET /tasks/TASK-9999 → 400 for what's semantically the same "not found" case.
Suggested fix — check the identifier's own prefix before hitting the DB, so a malformed param (no digits, no recognized prefix) still 400s early, but a well-formed-but-missing identifier falls through to the existing 404 at the Get* call:
func (s *Server) resolveTaskID(ctx context.Context, orgID int, param string) (int64, error) {
if n, err := strconv.ParseInt(param, 10, 64); err == nil {
return n, nil
}
if !strings.HasPrefix(param, "TASK-") {
return 0, fmt.Errorf("%w: %q", errInvalidID, param)
}
t, err := s.db.GetTaskByIdentifier(ctx, orgID, param)
if err != nil {
return 0, err // not-found — let the caller 404, not 400
}
return t.ID, nil
}Then at each call site, only 400 when the error is errInvalidID; anything else (a real GetTaskByIdentifier miss) should 404:
id, err := s.resolveTaskID(c.Request().Context(), orgID, c.Param("id"))
if errors.Is(err, errInvalidID) {
return echo.NewHTTPError(http.StatusBadRequest, "invalid task id")
} else if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "task not found")
}Same shape applies to resolveIncidentID ("INC-"), resolveCorrectiveActionID ("CA-"), and resolveLegalID ("LEGAL-"). errInvalidID can be a single package-level errors.New("invalid id") sentinel declared near parseID.
2. [should-fix] internal/isms/api/api_suggestions.go line 791
applyIncidentUpdate/applyCorrActiveUpdate/applyTaskUpdate still resolve sg.EntityID via parseEntityID, which strips the identifier prefix and treats the numeric suffix directly as the primary key passed to GetIncident/GetCorrectiveAction/GetTask. That's the exact bug class this PR's second commit just fixed for URL params (identifier suffix is a per-org identifier_sequences value, not the row id, for task/incident/CA/legal) — just on the suggestion-apply path instead of the HTTP :id param path. Confirmed sg.EntityID is always the identifier form here (e.g. INC-3), not a bare numeric id — the Vue side passes selectedIncident.identifier / selectedTask.identifier / selectedCA.identifier as entityId into SuggestionPanel/CommentsPanel (Incidents.vue:475, Tasks.vue:342, CorrectiveActions.vue:356), which becomes entity_id on the suggestion.
The codebase already has the correct pattern two callers away — applyRiskUpdate / applySupplierUpdate / applyLegalReading resolve via GetRiskByIdentifier / GetSupplierByIdentifier / GetLegalRequirementByIdentifier directly. Suggested fix — mirror that pattern (all three GetXByIdentifier helpers already exist, including the GetTaskByIdentifier this PR just added):
// applyIncidentUpdate
inc, err := s.db.GetIncidentByIdentifier(ctx, orgID, sg.EntityID)
if err != nil {
return "", fmt.Errorf("incident %s not found: %w", sg.EntityID, err)
}
// drop the `parseEntityID(sg.EntityID)` call above it — incID is no longer needed
// applyCorrActiveUpdate
ca, err := s.db.GetCorrectiveActionByIdentifier(ctx, orgID, sg.EntityID)
if err != nil {
return "", fmt.Errorf("corrective action %s not found: %w", sg.EntityID, err)
}
// applyTaskUpdate
t, err := s.db.GetTaskByIdentifier(ctx, orgID, sg.EntityID)
if err != nil {
return "", fmt.Errorf("task %s not found: %w", sg.EntityID, err)
}3. [nit] internal/isms/api/server.go line 306
This comment states findings and SUPPLIER-/ASSET-/RISK-/SYSTEM- identifiers "work" with parseID because the suffix IS the primary key — true for findings (FIND-<id> is built directly from the row id, per SoftDeleteAuditFinding), but for the other four nextIdentifierTx mints them from the same per-org identifier_sequences counter used for task/incident/CA/legal, so they have the same theoretical seq-vs-id divergence risk, just not yet observed/tested.
Suggested fix — soften the comment so it doesn't read as a structural guarantee:
// parseID accepts a bare numeric id, or an identifier whose numeric SUFFIX is used
// directly as the primary key: findings (FIND-<id>, built from the row id itself — see
// SoftDeleteAuditFinding) and, so far incidentally rather than by design, the
// pre-existing SUPPLIER-/ASSET-/RISK-/SYSTEM- handlers (same identifier_sequences
// mechanism as TASK-/INC-/CA-/LEGAL-, just not yet observed to diverge — see #174 PR
// discussion). It does NOT work for seq-based identifiers where the suffix is known to
// diverge from the id (TASK-/INC-/CA-/LEGAL-) — those resolve via the resolve*ID lookups
// below.…comment - resolve*ID: add errInvalidID sentinel + prefix check. A malformed param (no digits, wrong/no prefix) → 400; a well-formed-but-missing identifier returns the lookup error, which call sites now map to 404 — matching the numeric path. - suggestion-apply: applyIncidentUpdate/applyCorrActiveUpdate/applyTaskUpdate resolved sg.EntityID via parseEntityID (suffix-as-id) — the same seq!=id bug. Now resolve via GetIncidentByIdentifier/GetCorrectiveActionByIdentifier/ GetTaskByIdentifier (all exist). - soften the parseID comment: supplier/asset/risk/system happen to be seq==id, not a structural guarantee.
|
Thanks @unidoc-alip — all three addressed, and a note on CI: 1. [should-fix] 404 vs 400 in resolve*ID. Added an 2. [should-fix] suggestion-apply seq bug. 3. [nit] parseID comment. Softened per your wording. On CI: the red integration job is a flake, not this PR — the one failure is
|
… form (#174) My earlier review commit switched applyIncidentUpdate/applyCorrActiveUpdate/ applyTaskUpdate to GetXByIdentifier(sg.EntityID). But sg.EntityID isn't always the identifier form — test_corrective_writepath / test_incident_writepath post entity_id: str(ca_id) (the numeric id), and GetByIdentifier("7") looks up WHERE identifier='7' → no rows → the 'corrective action 7 not found' 500 in CI. Route through the resolve*ID helpers instead, which accept EITHER a numeric id OR the PREFIX-<seq> identifier (numeric passes straight to Get*, identifier resolves via GetXByIdentifier). Fixes both the numeric-entity_id tests and the SPA's identifier-form suggestions.
|
CI fix — and it was my regression from the review commit, not a flake (thanks for pushing back on that). My earlier commit routed suggestion-apply through Fixed by routing through the Note: the follow-up issue I filed for change/objective/system/asset should use this same both-forms |
* feat(tasks): per-task private visibility + org default Tasks can be marked private: a private task is visible only to its assignee, its creator, and managers/admins. PUBLIC by default, so existing tasks and existing deployments are unchanged; an org opts into privacy-by-default via the task_default_private setting. - Migration (v0.7.1): tasks.private column + task_default_private settings-catalog row (renders as a boolean toggle in Admin -> Settings). - db.TaskViewer + visibilityClause: one predicate (NOT private OR assignee=me OR created_by=me), applied to the task read paths (ListTasks/PaginatedTasks/ ListTasksWhere). CanSeeAll (manager/admin) leaves queries unchanged. NO RLS. - Single by-id fetches enforce canViewTask in the handler -> 404 (not 403) so a private task's existence isn't revealed. Entity-reference title resolution hides a private task's title from non-viewers. - Token safety: visibility keys off the token USER's role, never a token flag, so a reader/contributor/agent token can't see others' private tasks; there is deliberately no 'see-private' token attribute (a manager/admin service user with a read-only token is the way to read all private tasks). - Create form gets a Public/Private control seeded from the org default (exposed via /config); the API create/update accept an optional private field. - Universal search indexes public tasks only (shared/cached index can't be filtered per viewer) so titles can't leak; owner-searchable private tasks are a tracked follow-up. Tests: db.TaskViewer.visibilityClause + api.canViewTask (public/manager/admin/ assignee/creator visible; unrelated reader/contributor incl. agents hidden). * fix(suggestions): resolve seq-based identifiers on apply (change/objective/system/asset) (#177) applyChange/Objective/System/AssetUpdate resolved the target entity with parseEntityID (strip prefix -> int) and then GetX(thatInt). The identifier suffix is a per-org sequence, not the primary key, so on a shared/persistent stack an identifier-form suggestion (CR-n / SYSTEM-n / ASSET-n / objective display_id) silently hit the WRONG row — or 'not found'. Same class of bug #174 fixed for task/incident/corrective_action. - New resolveChangeID/resolveObjectiveID/resolveSystemID/resolveAssetID: numeric -> id; otherwise look it up (GetChangeRequestByIdentifier [new] / GetObjectiveByDisplayID / GetSystemByIdentifier / GetAssetByIdentifier). These intentionally skip a fixed-prefix check: asset ids exist as ASSET-/AST-, systems as SYSTEM-, and objective identifiers are program-key display IDs (e.g. ISMS-1). - The four apply handlers now resolve via those helpers instead of parseEntityID. - Regression test (Python/integration): create each entity, apply an update addressing it by its identifier form, assert the correct row changed. Note (separate, not fixed here): the stale-detection path (parseEntityID at the top of the apply/get flow) has the same mis-resolution for these types, but it only affects staleness accuracy, not which row is mutated.
…181) * feat(tasks): per-task private visibility + org default Tasks can be marked private: a private task is visible only to its assignee, its creator, and managers/admins. PUBLIC by default, so existing tasks and existing deployments are unchanged; an org opts into privacy-by-default via the task_default_private setting. - Migration (v0.7.1): tasks.private column + task_default_private settings-catalog row (renders as a boolean toggle in Admin -> Settings). - db.TaskViewer + visibilityClause: one predicate (NOT private OR assignee=me OR created_by=me), applied to the task read paths (ListTasks/PaginatedTasks/ ListTasksWhere). CanSeeAll (manager/admin) leaves queries unchanged. NO RLS. - Single by-id fetches enforce canViewTask in the handler -> 404 (not 403) so a private task's existence isn't revealed. Entity-reference title resolution hides a private task's title from non-viewers. - Token safety: visibility keys off the token USER's role, never a token flag, so a reader/contributor/agent token can't see others' private tasks; there is deliberately no 'see-private' token attribute (a manager/admin service user with a read-only token is the way to read all private tasks). - Create form gets a Public/Private control seeded from the org default (exposed via /config); the API create/update accept an optional private field. - Universal search indexes public tasks only (shared/cached index can't be filtered per viewer) so titles can't leak; owner-searchable private tasks are a tracked follow-up. Tests: db.TaskViewer.visibilityClause + api.canViewTask (public/manager/admin/ assignee/creator visible; unrelated reader/contributor incl. agents hidden). * fix(suggestions): resolve seq-based identifiers on apply (change/objective/system/asset) (#177) applyChange/Objective/System/AssetUpdate resolved the target entity with parseEntityID (strip prefix -> int) and then GetX(thatInt). The identifier suffix is a per-org sequence, not the primary key, so on a shared/persistent stack an identifier-form suggestion (CR-n / SYSTEM-n / ASSET-n / objective display_id) silently hit the WRONG row — or 'not found'. Same class of bug #174 fixed for task/incident/corrective_action. - New resolveChangeID/resolveObjectiveID/resolveSystemID/resolveAssetID: numeric -> id; otherwise look it up (GetChangeRequestByIdentifier [new] / GetObjectiveByDisplayID / GetSystemByIdentifier / GetAssetByIdentifier). These intentionally skip a fixed-prefix check: asset ids exist as ASSET-/AST-, systems as SYSTEM-, and objective identifiers are program-key display IDs (e.g. ISMS-1). - The four apply handlers now resolve via those helpers instead of parseEntityID. - Regression test (Python/integration): create each entity, apply an update addressing it by its identifier form, assert the correct row changed. Note (separate, not fixed here): the stale-detection path (parseEntityID at the top of the apply/get flow) has the same mis-resolution for these types, but it only affects staleness accuracy, not which row is mutated. * fix(suggestions): confirm a submitted suggestion is in review (#167) Submitting a suggestion via SuggestNewButton closed the form and emitted 'created' with no feedback; on a list view the suggestion isn't shown there, so it felt like it vanished. Both create paths (SuggestNewButton and the SuggestionPanel inline form) now show a success toast: 'Suggestion submitted — a manager will review it before it takes effect.'
Closes #172 and #173.
Identifier URLs + int64. Task/incident/corrective-action/audit-finding/legal endpoints parsed the
:idparam with rawstrconv.Atoi, so only a numeric id worked and the identifier form (TASK-6, INC-3, …) returned 400 — asymmetric with suppliers/assets/risks/systems. Now they route throughparseID(extended to strip every prefixNextIdentifiermints), and those five entities' id is standardized to int64 to match the rest of the codebase so the helper drops in cleanly.AST- → ASSET-. Suggestion-applied assets were minted
AST-Nwhile HTTP-created ones wereASSET-N. The suggestion path now uses the sharednextIdentifierTx(…, "asset")helper (→ASSET-);parseIDstill stripsAST-so any legacy rows keep resolving.Regression test (
tests/test_identifier_urls.py) asserts the identifier and numeric forms resolve the same record for tasks/incidents/CAs/legal (GET/PUT/DELETE), and that garbage ids still 400.go build+just test-gogreen.