S3 compatibility and openapi spec.

This commit is contained in:
2026-03-03 00:24:45 +01:00
parent 66e3db44dc
commit cfec3afb49
5 changed files with 394 additions and 3 deletions

View File

@@ -51,6 +51,10 @@ Required when `AUTH_ENABLED=true`:
Reference: `auth/README.md` Reference: `auth/README.md`
Additional docs:
- Admin OpenAPI spec: `docs/admin-api-openapi.yaml`
- S3 compatibility matrix: `docs/s3-compatibility.md`
Health: Health:
- `GET /healthz` - `GET /healthz`
- `HEAD /healthz` - `HEAD /healthz`

View File

@@ -178,8 +178,6 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) {
authType = "sigv4-presign" authType = "sigv4-presign"
} }
// Admin API authorization is enforced in admin handlers (bootstrap-only).
// We still require valid SigV4 credentials here, but skip S3 action policy checks.
if strings.HasPrefix(r.URL.Path, "/_admin/") { if strings.HasPrefix(r.URL.Path, "/_admin/") {
return RequestContext{ return RequestContext{
Authenticated: true, Authenticated: true,

336
docs/admin-api-openapi.yaml Normal file
View File

@@ -0,0 +1,336 @@
openapi: 3.1.0
info:
title: fs Admin API
version: 1.0.0
description: |
JSON admin API for managing local users and policies.
Notes:
- Base path is `/_admin/v1`.
- Requests must be AWS SigV4 signed.
- Only the bootstrap access key is authorized for admin endpoints.
servers:
- url: http://localhost:2600
description: Local development
security:
- AwsSigV4: []
paths:
/_admin/v1/users:
post:
summary: Create user
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
$ref: '#/components/responses/InvalidRequest'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/UserAlreadyExists'
'503':
$ref: '#/components/responses/AuthDisabled'
'500':
$ref: '#/components/responses/InternalError'
get:
summary: List users
operationId: listUsers
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 1000
default: 100
- name: cursor
in: query
required: false
schema:
type: string
responses:
'200':
description: User summaries
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
'400':
$ref: '#/components/responses/InvalidRequest'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/AuthDisabled'
'500':
$ref: '#/components/responses/InternalError'
/_admin/v1/users/{accessKeyId}:
get:
summary: Get user with policy
operationId: getUser
parameters:
- $ref: '#/components/parameters/AccessKeyId'
responses:
'200':
description: User details
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/UserNotFound'
'503':
$ref: '#/components/responses/AuthDisabled'
'500':
$ref: '#/components/responses/InternalError'
delete:
summary: Delete user
operationId: deleteUser
parameters:
- $ref: '#/components/parameters/AccessKeyId'
responses:
'204':
description: User deleted
'400':
$ref: '#/components/responses/InvalidRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/UserNotFound'
'503':
$ref: '#/components/responses/AuthDisabled'
'500':
$ref: '#/components/responses/InternalError'
/_admin/v1/users/{accessKeyId}/policy:
put:
summary: Replace user policy
operationId: setUserPolicy
parameters:
- $ref: '#/components/parameters/AccessKeyId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SetPolicyRequest'
responses:
'200':
description: User details with updated policy
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
$ref: '#/components/responses/InvalidRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/UserNotFound'
'503':
$ref: '#/components/responses/AuthDisabled'
'500':
$ref: '#/components/responses/InternalError'
/_admin/v1/users/{accessKeyId}/status:
put:
summary: Set user status
operationId: setUserStatus
parameters:
- $ref: '#/components/parameters/AccessKeyId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SetStatusRequest'
responses:
'200':
description: User details with updated status
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
$ref: '#/components/responses/InvalidRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/UserNotFound'
'503':
$ref: '#/components/responses/AuthDisabled'
'500':
$ref: '#/components/responses/InternalError'
components:
securitySchemes:
AwsSigV4:
type: apiKey
in: header
name: Authorization
description: |
AWS Signature Version 4 headers are required (`Authorization`, `x-amz-date`,
and for payload-signed requests `x-amz-content-sha256`).
Only bootstrap credential is authorized for admin endpoints.
parameters:
AccessKeyId:
name: accessKeyId
in: path
required: true
schema:
type: string
description: User access key ID
responses:
InvalidRequest:
description: Invalid request input
content:
application/json:
schema:
$ref: '#/components/schemas/AdminError'
Forbidden:
description: Authenticated but not allowed
content:
application/json:
schema:
$ref: '#/components/schemas/AdminError'
UserAlreadyExists:
description: User already exists
content:
application/json:
schema:
$ref: '#/components/schemas/AdminError'
UserNotFound:
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/AdminError'
AuthDisabled:
description: Authentication subsystem disabled
content:
application/json:
schema:
$ref: '#/components/schemas/AdminError'
InternalError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/AdminError'
schemas:
AdminError:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
required: [code, message]
PolicyStatement:
type: object
properties:
effect:
type: string
enum: [allow, deny]
actions:
type: array
items:
type: string
minItems: 1
bucket:
type: string
default: "*"
prefix:
type: string
default: "*"
required: [effect, actions]
Policy:
type: object
properties:
principal:
type: string
description: Server-managed; overwritten with target access key ID.
statements:
type: array
items:
$ref: '#/components/schemas/PolicyStatement'
minItems: 1
required: [statements]
CreateUserRequest:
type: object
properties:
accessKeyId:
type: string
secretKey:
type: string
description: If omitted, server generates one.
status:
type: string
enum: [active, disabled]
default: active
policy:
$ref: '#/components/schemas/Policy'
required: [accessKeyId, policy]
SetPolicyRequest:
type: object
properties:
policy:
$ref: '#/components/schemas/Policy'
required: [policy]
SetStatusRequest:
type: object
properties:
status:
type: string
enum: [active, disabled]
required: [status]
UserListItem:
type: object
properties:
accessKeyId:
type: string
status:
type: string
enum: [active, disabled]
createdAt:
type: integer
format: int64
updatedAt:
type: integer
format: int64
required: [accessKeyId, status, createdAt, updatedAt]
UserListResponse:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/UserListItem'
nextCursor:
type: string
required: [items]
UserResponse:
allOf:
- $ref: '#/components/schemas/UserListItem'
- type: object
properties:
policy:
$ref: '#/components/schemas/Policy'
secretKey:
type: string
description: Returned only on create.

53
docs/s3-compatibility.md Normal file
View File

@@ -0,0 +1,53 @@
# S3 Compatibility Matrix
This project is S3-compatible for a focused subset of operations.
## Implemented
### Service and account
- `GET /` list buckets
### Bucket
- `PUT /{bucket}` create bucket
- `HEAD /{bucket}` head bucket
- `DELETE /{bucket}` delete bucket (must be empty)
- `GET /{bucket}?list-type=2...` list objects v2
- `GET /{bucket}?location` get bucket location
- `POST /{bucket}?delete` delete multiple objects
### Object
- `PUT /{bucket}/{key}` put object
- `GET /{bucket}/{key}` get object
- `HEAD /{bucket}/{key}` head object
- `DELETE /{bucket}/{key}` delete object
- `GET /{bucket}/{key}` supports single-range requests
### Multipart upload
- `POST /{bucket}/{key}?uploads` initiate
- `PUT /{bucket}/{key}?uploadId=...&partNumber=N` upload part
- `GET /{bucket}/{key}?uploadId=...` list parts
- `POST /{bucket}/{key}?uploadId=...` complete
- `DELETE /{bucket}/{key}?uploadId=...` abort
### Authentication
- AWS SigV4 header auth
- AWS SigV4 presigned query auth
- `aws-chunked` payload decode for streaming uploads
## Partially Implemented / Differences
- Exact parity with AWS S3 error codes/headers is still evolving.
- Some S3 edge-case behaviors may differ (especially uncommon query/header combinations).
- Admin API is custom JSON (`/_admin/v1/*`).
## Not Implemented (Current)
- Bucket versioning
- Lifecycle rules
- Replication
- Object lock / legal hold / retention
- SSE-S3 / SSE-KMS / SSE-C
- ACL APIs and IAM-compatible policy APIs
- STS / temporary credentials
- Event notifications
- Tagging APIs
- CORS APIs
- Website hosting APIs

View File

@@ -135,7 +135,7 @@ func (bs *BlobStore) saveBlob(chunkID string, data []byte) error {
if err := os.Rename(tmpPath, fullPath); err != nil { if err := os.Rename(tmpPath, fullPath); err != nil {
if _, statErr := os.Stat(fullPath); statErr == nil { if _, statErr := os.Stat(fullPath); statErr == nil {
success = true :q success = true
return nil return nil
} }
return err return err