Security & Authorization Model
Comprehensive reference for the JWST Data Analysis application's security model: user roles, data visibility, endpoint authorization, and access control patterns.
Last updated: 2026-04-20 (#1173 enumeration hardening for POST /generate-and-save)
User Roles
| Role |
How Assigned |
Description |
| Admin |
Role claim = "Admin" in JWT |
Full access to all data and operations. Can bypass ownership checks. |
| Authenticated User |
Valid JWT with sub / NameIdentifier claim |
Can create, own, and manage their own data. Can read public and shared data. |
| Anonymous |
No JWT / unauthenticated |
Read-only access to public data. Cannot create, modify, or delete anything. |
Data Access Control Fields
Each JwstDataRecord in MongoDB has four fields that determine who can access it:
| Field |
Type |
Default |
Purpose |
UserId |
string? |
null |
Owner of the record. Set at import/upload time. |
IsPublic |
bool |
false |
When true, readable by everyone including anonymous users. |
SharedWith |
List<string> |
[] |
Additional user IDs granted read access to private data. |
IsArchived |
bool |
false |
Soft-delete flag. Hidden from default listings but still accessible by ID. |
Design decision: IsPublic defaults to false (secure by default). User-uploaded data is private until explicitly shared. MAST-imported data explicitly sets IsPublic = true since JWST observations are public domain.
Access Control Matrix
Data Read Access
Who can view/read a record (GET by ID, preview, histogram, pixel data, thumbnail, file download, spectral data, analysis):
| Data State |
Admin |
Owner |
Shared User |
Other Auth User |
Anonymous |
Public (IsPublic=true) |
Yes |
Yes |
Yes |
Yes |
Yes |
Private, shared (IsPublic=false, in SharedWith) |
Yes |
Yes |
Yes |
No |
No |
Private, not shared (IsPublic=false, empty SharedWith) |
Yes |
Yes |
No |
No |
No |
| Archived + Public |
Yes |
Yes |
Yes |
Yes |
Yes |
| Archived + Private |
Yes |
Yes |
Shared only |
No |
No |
Response for denied access: Most endpoints return 404 Not Found (not 403 Forbidden) to prevent ID enumeration.
Data Mutation Access
Who can modify a record (update metadata, share/publish, archive, unarchive):
| Action |
Admin |
Owner |
Shared User |
Other Auth User |
Anonymous |
| Update metadata (PUT) |
Yes |
Yes |
No (403) |
No (403) |
No (401) |
| Share / change visibility |
Yes |
Yes |
No (403) |
No (403) |
No (401) |
| Archive |
Yes |
Yes |
No (403) |
No (403) |
No (401) |
| Unarchive |
Yes |
Yes |
No (403) |
No (403) |
No (401) |
Data Deletion Access
Who can delete data:
| Action |
Admin |
Owner |
Shared User |
Other Auth User |
Anonymous |
| Delete single record |
Yes |
Yes |
No (403) |
No (403) |
No (401) |
| Delete entire observation |
Yes |
Yes (all files must be owned) |
No (403) |
No (403) |
No (401) |
| Delete observation level |
Yes |
Yes (all files must be owned) |
No (403) |
No (403) |
No (401) |
| Archive observation level |
Yes |
Yes (all files must be owned) |
No (403) |
No (403) |
No (401) |
Data Creation Access
| Action |
Admin |
Auth User |
Anonymous |
| Upload FITS file |
Yes |
Yes (becomes owner) |
No (401) |
| Import from MAST |
Yes |
Yes (becomes owner) |
No (401) |
| Create via POST |
Yes |
Yes (becomes owner) |
No (401) |
Computed/Generated Data
| Action |
Admin |
Auth User (accessible inputs) |
Auth User (inaccessible inputs) |
Anonymous (public inputs) |
Anonymous (private inputs) |
| Generate mosaic |
Yes |
Yes |
403 |
Public inputs only |
No |
| Generate composite |
Yes |
Yes |
404 |
Public inputs only |
No |
| Run analysis |
Yes |
Yes |
403 |
Public inputs only |
No |
| Export (mosaic/composite) |
Yes |
Yes |
— |
No (401) |
No (401) |
Authorization Helpers
All controllers inherit from ApiControllerBase, which provides identity extraction. Access control helpers are in individual controllers:
Base Class (ApiControllerBase)
| Method |
Returns |
Logic |
GetCurrentUserId() |
string? |
Reads NameIdentifier or sub claim from JWT. Returns null for unauthenticated. |
GetRequiredUserId() |
string |
Same, but throws UnauthorizedAccessException if null. Use in [Authorize] endpoints. |
IsCurrentUserAdmin() |
bool |
User.IsInRole("Admin") |
Controller-Level Helpers
| Method |
Location |
Logic |
IsDataAccessible(record) |
JwstDataController, AnalysisController |
Unauthenticated → IsPublic only. Authenticated → IsPublic OR owner OR SharedWith OR admin. |
CanModifyData(record) |
JwstDataController |
owner OR admin. Shared users cannot modify. |
CanAccessData(record) |
JwstDataController |
Authenticated-only variant: IsPublic OR owner OR SharedWith OR admin. |
FilterAccessibleData(list) |
JwstDataController, DataManagementController |
Filters a list to only records the current user can access. |
Service-Level Helpers
| Method |
Location |
Logic |
CanAccessData(record, userId, isAuthenticated, isAdmin) |
MosaicService, CompositeService |
Same logic as controller version, but accepts explicit user context (for background jobs). |
Endpoint Authorization Reference
Legend
- Open: No authentication required (
[AllowAnonymous])
- Auth: Requires valid JWT (
[Authorize])
- Admin: Requires admin role (
[Authorize(Policy="AdminOnly")])
- + Access Check: Endpoint performs per-record authorization beyond the attribute
AuthController (/api/auth)
| Endpoint |
Auth |
Internal Check |
Notes |
POST /login |
Open |
None |
|
POST /register |
Open |
None |
|
POST /refresh |
Open |
None |
|
POST /logout |
Auth |
UserId null-check |
|
GET /me |
Auth |
UserId null-check |
|
POST /change-password |
Auth |
UserId null-check |
Own password only |
JwstDataController (/api/jwstdata)
| Endpoint |
Auth |
Internal Check |
Notes |
GET / |
Open |
+ FilterAccessibleData |
Anon → public; Auth → own+public+shared; Admin → all |
GET /{id} |
Open |
+ IsDataAccessible |
404 if inaccessible |
GET /{id}/preview |
Open |
+ IsDataAccessible |
|
GET /{id}/histogram |
Open |
+ IsDataAccessible |
|
GET /{id}/pixeldata |
Open |
+ IsDataAccessible |
|
GET /{id}/cubeinfo |
Open |
+ IsDataAccessible |
|
GET /{id}/file |
Open |
+ IsDataAccessible |
|
GET /{id}/processing-results |
Open |
+ IsDataAccessible |
|
GET /{id}/thumbnail |
Open |
+ IsDataAccessible |
Returns 404 (not 403) to prevent enumeration |
GET /type/{dataType} |
Open |
+ FilterAccessibleData |
|
GET /status/{status} |
Open |
+ FilterAccessibleData |
|
GET /tags/{tags} |
Open |
+ FilterAccessibleData |
|
GET /statistics |
Open |
None |
Aggregate counts only |
GET /public |
Open |
None |
DB query for public records |
GET /validated |
Open |
+ FilterAccessibleData |
|
GET /format/{fileFormat} |
Open |
+ FilterAccessibleData |
|
GET /tags |
Open |
None |
Tag list only |
GET /lineage/{obsBaseId} |
Open |
+ FilterAccessibleData |
|
GET /lineage |
Open |
+ FilterAccessibleData |
|
GET /archived |
Auth |
+ FilterAccessibleData |
|
GET /user/{userId} |
Auth |
Own userId or admin |
Non-admin gets 403 for other users |
POST / |
Auth |
Sets UserId to current |
|
POST /upload |
Auth |
Sets UserId to current |
|
POST /search |
Auth |
Non-admin filtered to own+public |
|
POST /{id}/share |
Auth |
+ CanModifyData |
Owner or admin |
POST /{id}/archive |
Auth |
+ CanModifyData |
Owner or admin |
POST /{id}/unarchive |
Auth |
+ CanModifyData |
Owner or admin |
POST /check-availability |
Open |
+ FilterAccessibleData |
|
PUT /{id} |
Auth |
+ CanModifyData |
Owner or admin |
DELETE /{id} |
Auth |
+ CanModifyData |
Owner or admin |
DELETE /observation/{obsBaseId} |
Auth |
All records must be owned (or admin) |
|
DELETE /observation/{obsBaseId}/level/{level} |
Auth |
All records must be owned (or admin) |
|
POST /observation/{obsBaseId}/level/{level}/archive |
Auth |
All records must be owned (or admin) |
|
POST /generate-thumbnails |
Admin |
Admin policy |
|
POST /bulk/tags |
Admin |
Admin policy |
|
POST /bulk/status |
Admin |
Admin policy |
|
POST /migrate/processing-levels |
Admin |
Admin policy |
|
POST /migrate/data-types |
Admin |
Admin policy |
|
AnalysisController (/api/analysis)
| Endpoint |
Auth |
Internal Check |
Notes |
POST /region-statistics |
Open |
+ IsDataAccessible |
Anon: 404 if inaccessible (anti-enumeration); Auth: 403 |
POST /detect-sources |
Open |
+ IsDataAccessible |
Anon: 404 if inaccessible (anti-enumeration); Auth: 403 |
GET /table-info |
Open |
+ IsDataAccessible |
Anon: 404 if inaccessible (anti-enumeration); Auth: 403 |
GET /table-data |
Open |
+ IsDataAccessible |
Anon: 404 if inaccessible (anti-enumeration); Auth: 403 |
GET /spectral-data |
Open |
+ IsDataAccessible |
Anon: 404 if inaccessible (anti-enumeration); Auth: 403 |
MosaicController (/api/mosaic)
| Endpoint |
Auth |
Internal Check |
Notes |
POST /generate |
Open |
+ Service-level CanAccessData per input |
Anon: public data only, returns 404 (not 403) for inaccessible |
POST /generate-and-save |
Auth |
+ Service-level CanAccessData per input |
Auth: 403 if accessible; 404 if inaccessible (defensive anti-enumeration if later [AllowAnonymous]) |
POST /footprint |
Open |
+ Service-level CanAccessData per input |
Anon: public data only, returns 404 (not 403) for inaccessible |
POST /export |
Auth |
UserId null-check |
Operates on generated output |
POST /save |
Auth |
UserId null-check |
|
GET /limits |
Open |
None |
Configuration values only |
CompositeController (/api/composite)
| Endpoint |
Auth |
Internal Check |
Notes |
POST /generate-nchannel |
Open |
+ Service-level access check per input |
404 if inaccessible to anon |
POST /export-nchannel |
Auth |
UserId null-check |
|
POST /analyze-channels |
Open |
+ Service-level access check per input |
404 if inaccessible to anon |
MastController (/api/mast)
| Endpoint |
Auth |
Internal Check |
Notes |
POST /search/target |
Open |
None |
MAST catalog query |
POST /search/coordinates |
Open |
None |
|
POST /search/observation |
Open |
None |
|
POST /search/program |
Open |
None |
|
POST /whats-new |
Open |
None |
|
POST /products |
Open |
None |
|
POST /download |
Auth |
None |
Downloads to server storage |
POST /import |
Auth |
Sets UserId to current |
|
GET /import-progress/{jobId} |
Auth |
Owner or admin (404 for others) |
|
POST /import/cancel/{jobId} |
Auth |
Passes userId to cancel |
|
POST /import/resume/{jobId} |
Auth |
Owner or admin (404 for others) |
|
POST /import/from-existing/{obsId} |
Auth |
Sets UserId |
|
GET /import/check-files/{obsId} |
Auth |
None (filesystem check) |
|
GET /import/resumable |
Auth |
User-scoped via job tracker (admin sees all) |
|
DELETE /import/resumable/{jobId} |
Auth |
Owner or admin (404 for others) |
|
POST /refresh-metadata/{obsId} |
Auth |
Owner-scoped (admin refreshes all) |
|
POST /refresh-metadata-all |
Admin |
Admin policy |
|
DataManagementController (/api/datamanagement)
| Endpoint |
Auth |
Internal Check |
Notes |
POST /search |
Open |
+ FilterAccessibleData (includes SharedWith) |
|
GET /statistics |
Open |
None (aggregates) |
|
GET /public |
Open |
None (public query) |
|
GET /validated |
Open |
+ FilterAccessibleData |
|
GET /format/{fileFormat} |
Open |
+ FilterAccessibleData |
|
GET /tags |
Open |
None (tag list) |
|
POST /export |
Auth |
+ FilterAccessibleData |
|
GET /export/{exportId} |
Auth |
Owner or admin (404 for others) |
Legacy exports without metadata remain accessible |
POST /import/scan |
Auth |
None |
|
POST /claim-orphaned |
Auth |
Sets UserId |
|
POST /bulk/tags |
Admin |
Admin policy |
|
POST /bulk/status |
Admin |
Admin policy |
|
POST /migrate-storage-keys |
Admin |
Admin policy |
|
DiscoveryController (/api/discovery)
| Endpoint |
Auth |
Internal Check |
Notes |
GET /featured |
Open |
None |
Curated content |
POST /suggest-recipes |
Open |
None |
AI suggestions |
JobsController (/api/jobs)
| Endpoint |
Auth |
Internal Check |
Notes |
GET / |
Auth |
User-scoped query |
Only own jobs (even for admin) |
GET /{jobId} |
Auth |
Owner check (404) |
|
POST /{jobId}/cancel |
Auth |
Owner check |
|
GET /{jobId}/result |
Auth |
Owner check (404) |
|
SearchController (/api/search)
| Endpoint |
Auth |
Internal Check |
Notes |
GET /semantic |
Open |
+ Per-record access filtering |
Filters results by IsPublic, owner, SharedWith, admin |
POST /reindex |
Admin |
Admin policy |
Triggers full re-index of all documents |
GET /index-status |
Open |
None |
Index health info only |
Python Processing Engine (internal)
The processing engine runs as an internal service behind the .NET API gateway. It has no authentication layer — all requests are trusted as pre-authorized by the .NET layer.
Routes: /mast/*, /analysis/*, /composite/*, /mosaic/*, /discovery/*, /semantic/*
Known Gaps
| Issue |
Description |
Severity |
| #1071 |
Deduplicate IsDataAccessible / FilterAccessibleData (tech debt) |
Low |
Previously tracked gaps #565-#570 were resolved in PR #573.
Design Decisions & Rationale
-
IsPublic defaults to true: JWST data is public domain. Import from MAST creates public records unless the user explicitly makes them private.
-
404 over 403 for read access: Most read endpoints return 404 Not Found for inaccessible data rather than 403 Forbidden. This prevents ID enumeration attacks — an attacker cannot distinguish "exists but private" from "does not exist".
-
Processing engine has no auth: The Python processing engine is an internal service not exposed to the internet. The .NET API acts as the auth gateway. This is a trust boundary — if the processing engine were ever exposed directly, it would need its own auth layer.
-
Service-level auth for background jobs: Mosaic and composite generation can run as background jobs. Since background jobs have no HTTP context, user identity is serialized into the job payload (UserId, IsAuthenticated, IsAdmin) and checked at the service layer.
-
Owner-only mutations, admin bypass: Only the record owner can modify or delete data. Admin can bypass all ownership checks. Shared users get read access only — they cannot modify shared data.
-
Observation-level deletes require full ownership: Deleting an entire observation or processing level requires that ALL records in the set belong to the requesting user. This prevents partial-ownership situations where one user could delete another's records in a shared observation.
Back to Architecture Overview