Skip to main content

Storage API

Heimdall provides file storage for uploads, downloads, and management via two backends: S3-compatible object storage (default) or a local filesystem backend. It supports any S3-compatible provider including AWS S3, MinIO, Cloudflare R2, and DigitalOcean Spaces.

Overview​

The Storage API allows users to upload, download, and manage files with configurable validation rules per category. It supports presigned URLs for direct browser uploads/downloads and includes full audit logging.

Key Features​

  • Dual backend - S3-compatible object storage or a local filesystem backend (see Backends below)
  • Presigned URLs - Generate upload/download URLs for direct browser access (S3 backend only for uploads)
  • Category-based limits - Configurable file size and MIME type restrictions per category
  • Permission-based access - RBAC permissions for fine-grained control
  • Audit logging - All file operations are logged with user and request context

Backends​

Storage is dispatched over a backend config value: "s3" (default) or "local". Both backends implement the same REST/GraphQL surface for upload, download, delete, metadata, listing, copy, and rename.

OperationS3 backendLocal backend
Upload (POST /v1/storage/upload)SupportedSupported (writes under local_path)
Download (GET /v1/storage/download/{key})SupportedSupported
Presigned download URLSupported (signed S3 URL)Supported — returns the direct GET /v1/storage/download/{key} URL (no signature; access control is via the download endpoint's own auth)
Presigned upload URL (POST /v1/storage/upload/presigned)SupportedNot supported — returns an error wrapping Operation not supported by this storage backend: presigned upload URLs (use the direct upload endpoint with local backend). Use the direct upload endpoint instead.
Multipart upload (initiate/part/complete/abort)Supported (internal StorageClient API; not currently exposed via REST/GraphQL for either backend)Not supported — StorageError::Unsupported
Delete / exists / metadata / list / copy / renameSupportedSupported

Files on the local backend are always served through the existing GET /v1/storage/download/{key} handler — there is no separate static file server.

Required Permissions​

Storage operations require specific RBAC permissions:

PermissionDescription
storage:readView storage files, metadata, and list files
storage:writeUpload files and get presigned upload URLs
storage:editReserved — defined but not currently enforced (no metadata-edit endpoint yet)
storage:downloadDownload files and get presigned download URLs
storage:deleteDelete files from storage

Upload Categories​

Files are organized into categories with configurable limits:

CategoryDefault Max SizeDefault Allowed Types
images5 MBimage/jpeg, image/png, image/webp, image/gif, image/svg+xml
documents25 MBapplication/pdf, text/plain, text/markdown, application/json, application/xml
videos500 MBvideo/mp4, video/webm, video/quicktime, video/x-msvideo, video/x-matroska
general100 MB/ (all types)

These limits are configurable in the API configuration.

REST API Endpoints​

Get Storage Limits​

Get the upload limits for all categories.

GET /v1/storage/limits
Authorization: Bearer YOUR_TOKEN

Response:

{
"enabled": true,
"bucket": "heimdall",
"limits": {
"images": {
"maxSizeMb": 5,
"maxSizeBytes": 5242880,
"allowedTypes": ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"]
},
"documents": {
"maxSizeMb": 25,
"maxSizeBytes": 26214400,
"allowedTypes": ["application/pdf", "text/plain", "text/markdown", "application/json", "application/xml"]
},
"videos": {
"maxSizeMb": 500,
"maxSizeBytes": 524288000,
"allowedTypes": ["video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/x-matroska"]
},
"general": {
"maxSizeMb": 100,
"maxSizeBytes": 104857600,
"allowedTypes": ["*/*"]
}
},
"presignedExpirySeconds": 3600,
"_links": {
"self": "https://api.example.com/v1/storage/limits"
}
}

Upload File​

Upload a file directly to storage.

POST /v1/storage/upload
Authorization: Bearer YOUR_TOKEN
Content-Type: multipart/form-data

Form Fields:

FieldTypeRequiredDescription
filefileYesThe file to upload
categorystringYesCategory: "images", "documents", "videos", or "general"
keystringNoCustom storage key (auto-generated if not provided)

Response:

{
"key": "images/user123/profile.jpg",
"url": "https://storage.example.com/images/user123/profile.jpg",
"contentType": "image/jpeg",
"sizeBytes": 245632,
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"bucket": "heimdall",
"_links": {
"self": "https://api.example.com/v1/storage/upload",
"download": "https://api.example.com/v1/storage/download/images/user123/profile.jpg"
}
}

Get Presigned Upload URL​

Get a presigned URL for direct browser upload.

POST /v1/storage/upload/presigned
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

Request Body:

{
"filename": "profile.jpg",
"contentType": "image/jpeg",
"category": "images"
}

Optional fields: customKey (custom storage key) and expirySeconds (URL expiry, default 3600).

Response:

{
"url": "https://storage.example.com/images/user123/abc123.jpg?X-Amz-Signature=...",
"key": "images/user123/abc123.jpg",
"method": "PUT",
"expiresAt": "2026-01-24T12:00:00Z",
"_links": {
"self": "https://api.example.com/v1/storage/upload/presigned"
}
}

Download File​

Download a file from storage.

GET /v1/storage/download/{key}
Authorization: Bearer YOUR_TOKEN

Path Parameters:

ParameterTypeDescription
keystringThe storage key (URL-encoded if contains special characters)

Response: The file content with appropriate Content-Type header.

Get Presigned Download URL​

Get a presigned URL for direct browser download.

GET /v1/storage/download/{key}/presigned
Authorization: Bearer YOUR_TOKEN

Query Parameters:

ParameterTypeDefaultDescription
expirySecondsinteger3600URL expiration time in seconds. Falls back to the configured presigned_expiry_seconds when omitted.

Response:

{
"url": "https://storage.example.com/images/user123/profile.jpg?X-Amz-Signature=...",
"key": "images/user123/profile.jpg",
"method": "GET",
"expiresAt": "2026-01-24T13:00:00Z",
"_links": {
"self": "https://api.example.com/v1/storage/download/images/user123/profile.jpg/presigned"
}
}

Get File Metadata​

Get metadata for a stored file.

GET /v1/storage/{key}/metadata
Authorization: Bearer YOUR_TOKEN

Response:

{
"key": "images/user123/profile.jpg",
"sizeBytes": 245632,
"contentType": "image/jpeg",
"lastModified": "2026-01-24T10:30:00Z",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"bucket": "heimdall",
"_links": {
"self": "https://api.example.com/v1/storage/images/user123/profile.jpg/metadata",
"download": "https://api.example.com/v1/storage/download/images/user123/profile.jpg"
}
}

List Files​

List files with optional prefix filtering.

GET /v1/storage/list
Authorization: Bearer YOUR_TOKEN

Query Parameters:

ParameterTypeDefaultDescription
prefixstring-Filter by key prefix (e.g., "images/user123/")
delimiterstring-Delimiter for hierarchical listing (usually "/")
maxKeysinteger1000Maximum number of results (S3 default when omitted)
continuationTokenstring-Token for pagination

Response:

{
"files": [
{
"key": "images/user123/profile.jpg",
"sizeBytes": 245632,
"lastModified": "2026-01-24T10:30:00Z",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
}
],
"prefixes": [],
"isTruncated": false,
"continuationToken": null,
"bucket": "heimdall",
"prefix": "images/user123/",
"_links": {
"self": "https://api.example.com/v1/storage/list"
}
}

Delete File​

Delete a file from storage. This endpoint:

  • Verifies the file exists before deletion
  • Deletes the file from S3 storage
  • Removes the file metadata from the database
  • Logs an audit event (file_deleted)

Requires storage:delete permission.

DELETE /v1/storage/{key}
Authorization: Bearer YOUR_TOKEN

Path Parameters:

ParameterTypeDescription
keystringThe storage key (URL-encoded if contains special characters)

Response (200 OK):

{
"message": "File deleted successfully"
}

Error Responses:

StatusDescription
401Unauthorized - invalid or missing token
403Forbidden - missing storage:delete permission
404File not found
500Storage operation failed

GraphQL API​

Queries​

storageLimits​

Get upload limits for all categories.

query {
storageLimits {
images {
maxSizeMb
allowedTypes
}
documents {
maxSizeMb
allowedTypes
}
videos {
maxSizeMb
allowedTypes
}
general {
maxSizeMb
allowedTypes
}
}
}

presignedDownloadUrl​

Get a presigned download URL.

query {
presignedDownloadUrl(key: "images/user123/profile.jpg", expirySeconds: 3600) {
url
key
expiresAt
}
}

fileMetadata​

Get file metadata.

query {
fileMetadata(key: "images/user123/profile.jpg") {
key
sizeBytes
contentType
lastModified
etag
}
}

listFiles​

List files with pagination.

query {
listFiles(input: { prefix: "images/user123/", maxKeys: 100 }) {
files {
key
sizeBytes
lastModified
etag
}
prefixes
isTruncated
continuationToken
bucket
prefix
}
}

Mutations​

getPresignedUploadUrl​

Get a presigned URL for uploading.

mutation {
getPresignedUploadUrl(input: {
filename: "profile.jpg"
contentType: "image/jpeg"
category: "images"
}) {
url
key
expiresAt
}
}

deleteFile​

Delete a file from storage. This mutation:

  • Verifies the file exists before deletion
  • Deletes the file from S3 storage
  • Removes the file metadata from the database
  • Logs an audit event (file_deleted)

Requires storage:delete permission.

mutation {
deleteFile(key: "images/user123/profile.jpg")
}

Response: Returns true on success.

Errors:

  • "Authentication required" - No valid auth token
  • "File not found" - The specified key doesn't exist
  • "Failed to delete file: ..." - S3 deletion failed

Audit Events​

All storage operations are logged:

EventDescription
file_createdFile uploaded to storage
file_downloadedReserved — constant defined but not currently emitted
file_editedReserved — constant defined but not currently emitted
file_deletedFile deleted from storage

Configuration​

Storage is configured in the API configuration file:

[storage]
enabled = true
backend = "s3" # "s3" (default) or "local"
local_path = "./uploads" # only used when backend = "local"
endpoint = "http://localhost:9000" # S3-compatible endpoint
region = "us-east-1"
access_key = "your-access-key"
secret_key = "your-secret-key"
bucket = "heimdall"
path_style = true # Required for MinIO
public_url = "" # Optional CDN URL

[storage.upload_limits.images]
max_size_mb = 5
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/gif"]

[storage.upload_limits.documents]
max_size_mb = 25
allowed_types = ["application/pdf", "text/plain", "application/json"]

[storage.upload_limits.videos]
max_size_mb = 500
allowed_types = ["video/mp4", "video/webm", "video/quicktime", "video/x-msvideo"]

[storage.upload_limits.general]
max_size_mb = 100
allowed_types = ["*/*"]
KeyTypeDefaultDescriptionEnv override
backendstring"s3"Storage backend: "s3" (S3/MinIO/R2/Spaces) or "local" (filesystem)HEIMDALL__STORAGE__BACKEND
local_pathstring?noneLocal filesystem base path; only used when backend = "local"HEIMDALL__STORAGE__LOCAL_PATH
note

The shipped platform/api/config/default.toml sets backend = "local" and local_path = "./uploads" for local/dev deployment. The Rust struct default (used if the key is omitted entirely) is backend = "s3" with local_path = None.

Local Filesystem Backend​

For simple deployments without an S3-compatible provider, point backend at "local":

[storage]
enabled = true
backend = "local"
local_path = "./uploads"

Uploaded files are written under local_path and served back via the standard GET /v1/storage/download/{key} endpoint — no additional static file server or reverse-proxy config is required. See Backends above for the operations the local backend does not support (multipart, presigned upload URLs).

Provider-Specific Configuration​

MinIO (Local Development)​

[storage]
endpoint = "http://localhost:9000"
region = "us-east-1"
path_style = true

Cloudflare R2​

[storage]
endpoint = "https://<account_id>.r2.cloudflarestorage.com"
region = "auto"
path_style = false
public_url = "https://pub-xxx.r2.dev"

DigitalOcean Spaces​

[storage]
endpoint = "https://<region>.digitaloceanspaces.com"
region = "<region>"
path_style = false
public_url = "https://<space>.<region>.cdn.digitaloceanspaces.com"

AWS S3​

[storage]
endpoint = "https://s3.<region>.amazonaws.com"
region = "<region>"
path_style = false

Storage File Metadata​

Track who uploaded what files and when with file metadata queries.

REST API​

List Storage Files (Admin)​

List all storage files with optional filtering. Requires storage:read permission.

GET /v1/storage/files
Authorization: Bearer YOUR_TOKEN

Query Parameters:

ParameterTypeDefaultDescription
pageinteger1Page number (1-indexed)
limitinteger20Items per page (max 100)
user_idstring-Filter by user ID
categorystring-Filter by category (images, documents, videos, general)
content_typestring-Filter by MIME type
key_prefixstring-Filter by key prefix
filename_searchstring-Search by filename (partial match)
start_datestring-Filter by start date (ISO 8601)
end_datestring-Filter by end date (ISO 8601)
sort_fieldstringcreated_atSort by: created_at, size_bytes, original_filename
sort_directionstringdescSort direction: asc or desc

Response:

{
"files": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "user123",
"key": "images/user123/profile.jpg",
"originalFilename": "profile.jpg",
"contentType": "image/jpeg",
"category": "images",
"sizeBytes": 245632,
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"bucket": "heimdall",
"createdAt": "2026-01-24T10:30:00Z"
}
],
"totalCount": 150,
"page": 1,
"limit": 20,
"totalPages": 8
}

Get File Info by Key​

Get metadata for a specific file by its storage key.

GET /v1/storage/files/{key}/info
Authorization: Bearer YOUR_TOKEN

List My Files​

List storage files uploaded by the current authenticated user.

GET /v1/storage/files/me
Authorization: Bearer YOUR_TOKEN

GraphQL API​

Query: storageFiles​

List all storage files with filters (admin, requires storage:read).

query {
storageFiles(input: {
category: "images"
filenameSearch: "profile"
limit: 20
sortField: "created_at"
sortDirection: "desc"
}) {
files {
id
key
originalFilename
contentType
sizeBytes
userId
createdAt
}
totalCount
totalPages
}
}

Query: storageFileByKey​

Get file metadata by storage key.

query {
storageFileByKey(key: "images/user123/profile.jpg") {
id
userId
originalFilename
sizeBytes
createdAt
}
}

Query: myStorageFiles​

List current user's files.

query {
myStorageFiles(
category: "images"
limit: 20
) {
files {
key
originalFilename
sizeBytes
createdAt
}
totalCount
}
}

Error Responses​

StatusCodeDescription
400NO_FILE / INVALID_CATEGORYNo file provided, or unknown upload category
401UNAUTHORIZEDAuthentication required
403FORBIDDENInsufficient permissions
404NOT_FOUNDFile not found
413FILE_TOO_LARGEFile exceeds the category size limit
415INVALID_MIME_TYPEMIME type not allowed for the category
500INTERNAL_ERRORStorage operation failed
503STORAGE_DISABLEDStorage service is not enabled