Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ jobs:
- name: Run automated tests
run: dotnet test FileSentry.slnx --configuration Release --no-build --no-restore

- name: Validate client package
run: dotnet pack src/FileSentry.Client/FileSentry.Client.csproj --configuration Release --no-build --no-restore --output artifacts/packages

- name: Verify formatting
run: dotnet format FileSentry.slnx --verify-no-changes --no-restore

Expand Down
1 change: 1 addition & 0 deletions FileSentry.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
<Project Path="tests/FileSentry.UnitTests/FileSentry.UnitTests.csproj" />
</Folder>
<Project Path="src/FileSentry.Api/FileSentry.Api.csproj" />
<Project Path="src/FileSentry.Client/FileSentry.Client.csproj" />
</Solution>
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,42 @@ A non-clean download returns `409` with code `FILE_NOT_CLEAN`. Missing and
cross-owner IDs return the same `404 FILE_NOT_FOUND` response. Errors use
`application/problem+json` with a stable `code` extension.

## C# client SDK

The pack-ready [.NET client](src/FileSentry.Client/README.md) wraps the existing
service-authenticated `/api/v1/files` contract. It is currently versioned
`1.1.0-preview.1` for development and has not been published to NuGet.

```csharp
using FileSentry.Client;

using var client = new FileSentryClient(new FileSentryClientOptions
{
BaseAddress = new Uri("https://filesentry.example/"),
ApiKey = configuration["FileSentry:ApiKey"]!,
PollingInterval = TimeSpan.FromSeconds(1),
ScanTimeout = TimeSpan.FromMinutes(5)
});

await using Stream uploadContent = File.OpenRead("cv.pdf");
FileUpload upload = await client.UploadAsync(uploadContent, "cv.pdf", cancellationToken);
FileMetadata result = await client.WaitForScanAsync(upload.FileId, cancellationToken);

if (result.Status == FileStatus.Clean)
{
await using Stream clean = await client.DownloadAsync(
upload.FileId,
cancellationToken);
// Consume the stream while still treating its content as potentially risky.
}
```

Use the sequence `Upload -> Wait for terminal scan status -> continue only if Clean
-> Download`. `Infected`, `ScanFailed`, and `Deleted` stop polling without becoming
successful. Unknown or malformed states fail safely. Uploads are not retried
automatically, and API ProblemDetails are available through typed, credential-
redacted SDK exceptions.

## Audit, correlation, and rate limiting

- Every request receives an `X-Correlation-ID`. A valid caller value is reused;
Expand Down Expand Up @@ -314,8 +350,8 @@ sample.
`.github/workflows/ci.yml` runs on pull requests and pushes to `main`. Its
least-privilege Ubuntu job installs the exact SDK, restores, validates Compose,
pre-pulls the existing Testcontainers images, builds in Release, builds the API
container image, runs the complete test suite, verifies formatting, and audits
direct and transitive NuGet packages.
container image, runs the complete test suite, validates the unpublished client
package, verifies formatting, and audits direct and transitive NuGet packages.

CI uses runtime-generated Testcontainers credentials and step-scoped Compose
placeholders. It requires no repository secrets or developer User Secrets. The
Expand Down
16 changes: 8 additions & 8 deletions docs/PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,14 @@ grows.
4. Owner-authorized metadata, download, and delete operations.
5. Audit, correlation, rate limiting, operational hardening, and CI.

### Current v1.1 phase: containerized API and service authentication

- run the API/worker, migrations, PostgreSQL, ClamAV, and persistent storage as one
Compose stack;
- keep database and scanner ports internal while exposing only the API;
- add an externally configured owner-scoped service credential without breaking
JWT callers;
- keep SDKs, quotas, retention, cloud storage, and messaging in later milestones.
### Current v1.1 phase: C# client SDK foundation

- provide a pack-ready .NET 10 client for service-authenticated upload, metadata,
list, scan polling, clean download, and delete;
- stream file content and preserve typed ProblemDetails without exposing the key;
- stop polling safely on clean and non-clean terminal states;
- keep NuGet publishing, Python SDK, webhooks, quotas, retention, cloud storage,
and messaging in later milestones.

Local verification results and release blockers are recorded in
`docs/ai/CURRENT_TASK.md`. No tag or release is created by this milestone.
Expand Down
118 changes: 60 additions & 58 deletions docs/ai/CURRENT_TASK.md
Original file line number Diff line number Diff line change
@@ -1,78 +1,80 @@
# Current Task: Containerized API and Service Authentication
# Current Task: C# Client SDK Foundation

## Milestone

`feat/containerized-api-service-auth` is the first FileSentry `v1.1.0` milestone.
It makes the existing API/worker deployable as part of one Docker Compose stack and
adds an additive credential for trusted backend callers without changing `/api/v1`
or weakening JWT user behavior.
`feat/client-sdk-foundation` is the second FileSentry `v1.1.0` milestone. It adds a
reusable .NET 10 client library for the existing `/api/v1/files` service API without
changing server workflow or authentication behavior.

## Scope

- Publish the .NET 10 API and background scanner in a production-style multi-stage
image that runs as a non-root user.
- Start the API/worker, PostgreSQL, a one-shot migration command, and ClamAV through
one Compose project with persistent temp/quarantine/clean storage.
- Keep PostgreSQL and ClamAV internal to the Compose network; only the API receives
an explicitly configured host binding.
- Add startup-validated `X-Api-Key` service authentication using constant-time
secret comparison and a configured passwordless internal owner identity.
- Permit service identities to upload, list, read metadata, download clean owned
files, and delete owned files. Do not permit service access to registration,
login, `/auth/me`, foreign files, or administrative behavior.
- Preserve Identity/JWT, scanning, ownership, audit/correlation, rate limiting,
health endpoints, and all existing tests.
- Add a packable `src/FileSentry.Client` project to the solution with pre-release
NuGet metadata, no publishing credentials, and no unnecessary dependencies.
- Provide asynchronous `UploadAsync`, `GetFileAsync`, `ListFilesAsync`,
`WaitForScanAsync`, `DownloadAsync`, and `DeleteAsync` operations.
- Attach the configured `X-Api-Key` to each SDK request without mutating shared
`HttpClient.DefaultRequestHeaders`.
- Stream caller-owned upload content and response-owned download content without
buffering whole files or exposing server storage details.
- Model public file metadata/status and preserve bounded ProblemDetails fields in
typed exceptions without exposing the configured credential.
- Poll at configurable intervals with a bounded timeout, stop on every terminal
state, and fail safely on unknown or malformed status responses.
- Add deterministic fake-handler tests plus a PostgreSQL-backed real API/worker
integration path that uploads, reaches `Clean`, downloads byte-identically, and
deletes through the SDK.

## Security decisions

- The service credential is supplied only through external configuration and is
never returned, persisted, or logged.
- A service ID is a stable GUID and remains subject to the same database owner
predicates and upload rate limiter as a JWT subject.
- The service owner row has no password and uses a reserved internal identifier;
migration/provisioning fails on an identity collision.
- Requests containing a Bearer header are validated as JWT even if an API-key
header is also present, preventing fallback from a bad bearer token.
- ClamAV continues receiving bytes through `INSTREAM`; storage is never mounted
into the scanner container.
- The server remains authoritative for hashing, file validation, ownership,
scanning, and clean-download authorization; the SDK duplicates none of those
decisions.
- `Clean` is the only status a consumer should use to continue to download or
process content. `Infected`, `ScanFailed`, and `Deleted` are terminal but never
successful; unknown states produce a protocol failure rather than being treated
as clean or polled indefinitely.
- Uploads are never retried automatically, and caller-provided upload streams are
not owned or disposed by the SDK.
- A returned download stream owns its HTTP response and releases it when disposed.
- API keys, upload bytes, and download bytes are not logged or included in SDK
exception text. Any reflected configured key is redacted from parsed error data.

## Out of scope

C# or Python SDKs, webhooks, quotas, retention, cloud storage, message brokers,
public sharing, admin UI, and API redesign remain later milestones.
NuGet publishing, Python SDK, webhooks, automatic upload retries, dependency-
injection extensions, quotas/retention, cloud storage, UI, API v2, and scanning
changes remain later milestones.

## Verification target

Restore, Release build, full tests, formatting, Compose validation, image build,
full-stack startup/migration/health, JWT and service-authenticated live calls, a
containerized clean upload/scan, internal-only dependency ports, vulnerability
audit, and final diff/status inspection must all be executed before completion.
Restore, zero-warning Release build, full tests, formatting, direct/transitive
NuGet vulnerability audit, package creation/metadata inspection, final diff check,
and Git status inspection must pass. The real API integration path must demonstrate
an SDK upload reaching `Clean` and a byte-identical streamed download.

## Verified implementation state

- The exact pinned .NET SDK restored and built the solution in Release with zero
warnings and zero errors.
- All 31 unit and 83 integration tests pass. Coverage includes valid/missing/invalid
service credentials, Bearer precedence, startup validation, passwordless service
provisioning, owner isolation, the service/JWT file lifecycle, and secret-safe
responses/logs.
- Formatting and the direct/transitive NuGet vulnerability audit pass; no known
vulnerable packages were reported by the configured sources.
- Compose configuration and the multi-stage API image build pass. A clean
disposable stack applied migrations, provisioned the service owner, reached
healthy live/ready status, and ran the API process as UID 1654.
- Only the API was published, on loopback for verification. PostgreSQL and ClamAV
had no host mappings. Fresh application volumes were owned by `app:app` with
mode `700`.
- Live calls verified registration/login/JWT access, valid/missing/invalid service
authentication, JWT/service cross-owner `404` behavior, a clean PDF upload from
`PendingScan` to `Clean`, byte-identical download, deletion, and JWT-only
`/auth/me`.
- Inspection found no configured signing key, service key, connection string, or
untrusted upload filename in API logs or HTTP response data.
- The public SDK exposes `UploadAsync`, `GetFileAsync`, `ListFilesAsync`,
`WaitForScanAsync`, `DownloadAsync`, and `DeleteAsync` with cancellation support.
- Uploads are multipart streamed without taking ownership of the caller stream;
downloads remain unread until consumed and release their HTTP response when the
returned stream is disposed.
- Fourteen deterministic SDK tests cover per-request credentials, streaming,
response models, download lifetime, delete, clean/infected/failed polling,
cancellation, ProblemDetails mapping/redaction, and malformed/unknown responses.
- A PostgreSQL-backed API/worker integration test verified SDK upload from
`PendingScan` to `Clean`, byte-identical streamed download, and deletion.
- The exact pinned SDK restored and built all four projects in Release with zero
warnings and zero errors. All 45 unit and 84 integration tests pass, and formatting
verification succeeds.
- The direct/transitive NuGet audit reports no known vulnerable packages from the
configured sources.
- `FileSentry.Client.1.1.0-preview.1.nupkg` packs successfully with the SDK README,
MIT expression, repository metadata, `net10.0` assembly, and no runtime package
dependencies. The disposable package was inspected and removed; nothing was
published.

The updated GitHub Actions workflow now also builds the API image. Its hosted run
on this uncommitted branch is not verifiable locally and remains the only
environment-specific check.
The updated hosted CI run remains unverified because this task does not commit or
push.

Recommended next milestone: `feat/client-sdk-foundation`.
Recommended next milestone: `feat/sdk-integration-examples`.
27 changes: 22 additions & 5 deletions docs/ai/PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The primary design rule is:
|---|---|
| Runtime | .NET 10, exact SDK pinned in `global.json` |
| API | ASP.NET Core controllers with development OpenAPI |
| C# client | Pack-ready .NET 10 `FileSentry.Client` library using service API-key authentication |
| Architecture | Modular monolith |
| Database | PostgreSQL 17 |
| Persistence | Entity Framework Core with Npgsql migrations |
Expand Down Expand Up @@ -45,6 +46,7 @@ FileSentry/
| |-- PROJECT_PLAN.md
| `-- threat-model.md
|-- src/FileSentry.Api/
|-- src/FileSentry.Client/
|-- tests/
| |-- FileSentry.UnitTests/
| `-- FileSentry.IntegrationTests/
Expand All @@ -66,6 +68,15 @@ owner metadata, streams only owner-controlled clean content, and coordinates
durable deletion. Service callers have file-owner scope only; they cannot use
registration, login, `/auth/me`, foreign-file, or administrative behavior.

### C# client

`FileSentry.Client` wraps the existing file routes with streaming async methods for
upload, metadata/list, bounded status polling, clean download, and delete. It adds
the service key to individual requests rather than shared default headers. It
returns typed file/status models and bounded, credential-redacted ProblemDetails
exceptions. The server remains authoritative for format validation, hashing,
ownership, scanning, and download permission.

### PostgreSQL

PostgreSQL stores users, file records, scan attempts, security audit events, retry
Expand Down Expand Up @@ -183,15 +194,16 @@ configured stable passwordless service owner before starting the API.

## Current verified state

The v1.0 MVP plus the first v1.1 milestone implement infrastructure health,
The v1.0 MVP plus the first two v1.1 milestones implement infrastructure health,
Identity/JWT and service authentication, secure ingestion, durable ClamAV scanning
and recovery, owner-protected file lifecycle, auditing/correlation/rate limiting,
an all-container Compose topology, adversarial tests, and GitHub Actions CI.
an all-container Compose topology, a pack-ready .NET client SDK, adversarial tests,
and GitHub Actions CI.

Integration tests use disposable PostgreSQL 17 and real ClamAV Testcontainers. The
final local gate passed from an isolated clone: a zero-warning Release build, 31
unit tests, 75 integration tests, formatting, migrations, Compose validation, and
the NuGet vulnerability audit all succeeded. Live clean, EICAR-infected,
final v1.0 local gate passed from an isolated clone with a zero-warning Release
build, the complete test suite, formatting, migrations, Compose validation, and the
NuGet vulnerability audit. Live clean, EICAR-infected,
retry-exhausted scanner-outage, ownership, audit/correlation, and rate-limit flows
also passed. Local development uses an ignored `deploy/.env` for Compose and .NET
User Secrets (or equivalent external configuration) for the database connection
Expand All @@ -205,6 +217,11 @@ Actions validates Compose and builds the API image in addition to the existing
solution gates. The v1.1 workflow change is locally validated but has not yet run on
a hosted runner because this task does not commit or push.

The SDK is versioned `1.1.0-preview.1` and intentionally remains unpublished. Its
safe consumer workflow is upload, wait for a terminal scan status, continue only on
`Clean`, then stream the download. A clean result reduces risk but is not a safety
guarantee.

## Limitations and explicit non-goals

- A clean antivirus result is not proof that a file is harmless.
Expand Down
11 changes: 11 additions & 0 deletions src/FileSentry.Client/FileMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace FileSentry.Client;

public sealed record FileMetadata(
Guid FileId,
string OriginalFileName,
long SizeBytes,
string Sha256,
string DetectedMediaType,
FileStatus Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
25 changes: 25 additions & 0 deletions src/FileSentry.Client/FileSentry.Client.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>FileSentry.Client</PackageId>
<Version>1.1.0-preview.1</Version>
<Authors>FileSentry Contributors</Authors>
<Description>Streaming .NET client for the FileSentry secure file-ingestion API.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/keremy321/FileSentry</PackageProjectUrl>
<RepositoryUrl>https://github.com/keremy321/FileSentry.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>filesentry;security;file-upload;malware-scanning</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
</PropertyGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

</Project>
52 changes: 52 additions & 0 deletions src/FileSentry.Client/FileSentryApiException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Net;

namespace FileSentry.Client;

public sealed class FileSentryApiException : FileSentryException
{
internal FileSentryApiException(
HttpStatusCode statusCode,
int? problemStatus,
string? code,
string? title,
string? detail,
string? type,
string? instance)
: base(CreateMessage(statusCode, code, title))
{
StatusCode = statusCode;
ProblemStatus = problemStatus;
Code = code;
Title = title;
Detail = detail;
Type = type;
Instance = instance;
}

public HttpStatusCode StatusCode { get; }

public int? ProblemStatus { get; }

public string? Code { get; }

public string? Title { get; }

public string? Detail { get; }

public string? Type { get; }

public string? Instance { get; }

private static string CreateMessage(
HttpStatusCode statusCode,
string? code,
string? title)
{
string safeTitle = string.IsNullOrWhiteSpace(title)
? "FileSentry API request failed"
: title;
return string.IsNullOrWhiteSpace(code)
? $"{safeTitle} (HTTP {(int)statusCode})."
: $"{safeTitle} (HTTP {(int)statusCode}, code {code}).";
}
}
Loading