From cfec3afb491451eb88ff6b348842abf5b6fa549f Mon Sep 17 00:00:00 2001 From: Andrej Mickov Date: Tue, 3 Mar 2026 00:24:45 +0100 Subject: [PATCH] S3 compatibility and openapi spec. --- README.md | 4 + auth/service.go | 2 - docs/admin-api-openapi.yaml | 336 ++++++++++++++++++++++++++++++++++++ docs/s3-compatibility.md | 53 ++++++ storage/blob.go | 2 +- 5 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 docs/admin-api-openapi.yaml create mode 100644 docs/s3-compatibility.md diff --git a/README.md b/README.md index bae663e..1a8c5cc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ Required when `AUTH_ENABLED=true`: Reference: `auth/README.md` +Additional docs: +- Admin OpenAPI spec: `docs/admin-api-openapi.yaml` +- S3 compatibility matrix: `docs/s3-compatibility.md` + Health: - `GET /healthz` - `HEAD /healthz` diff --git a/auth/service.go b/auth/service.go index 1f1ffb3..8ed0587 100644 --- a/auth/service.go +++ b/auth/service.go @@ -178,8 +178,6 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) { 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/") { return RequestContext{ Authenticated: true, diff --git a/docs/admin-api-openapi.yaml b/docs/admin-api-openapi.yaml new file mode 100644 index 0000000..fc5f744 --- /dev/null +++ b/docs/admin-api-openapi.yaml @@ -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. diff --git a/docs/s3-compatibility.md b/docs/s3-compatibility.md new file mode 100644 index 0000000..0850239 --- /dev/null +++ b/docs/s3-compatibility.md @@ -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 diff --git a/storage/blob.go b/storage/blob.go index 667958f..fa6a857 100644 --- a/storage/blob.go +++ b/storage/blob.go @@ -135,7 +135,7 @@ func (bs *BlobStore) saveBlob(chunkID string, data []byte) error { if err := os.Rename(tmpPath, fullPath); err != nil { if _, statErr := os.Stat(fullPath); statErr == nil { - success = true +:q success = true return nil } return err