Portal Community

On This Page

  1. Design Philosophy
  2. The Four Work Items
  3. Project Dependency Graph
  4. The IObjectStorageProvider Contract
  5. CID — Content Identity
  6. Capacity Guard
  7. SRP Class Decomposition
  8. Storage Key Strategy
  9. Deduplication Flow
Note This page explains the internal architecture of InfraHub Storage — projects, interfaces, dependency wiring, and design decisions. If you just want to store a file and move on, start with Getting Started.

Design Philosophy

InfraHub Storage is built around four foundational decisions that govern every design choice in the codebase. Understanding them makes the rest of the architecture self-evident.

Content-Addressable Identity (CID)

Every file is identified by its content, not by a caller-assigned name or a database-generated ID. A CID (Content Identifier) is the SHA-256 hash of the file's raw bytes, rendered as a lowercase hex string. This means:

Tenant Isolation at the Key Level

When multi-tenancy is enabled (PrependTenantID=true), every S3 object key is prefixed with tenant_{tenantID}/. This gives strong logical isolation within a shared bucket: one tenant's files are physically unreachable by another tenant's prefix, IAM bucket policies can be scoped to the prefix, and per-tenant data export or purge is a single S3 prefix operation. When single-tenant or testing mode is used (PrependTenantID=false), the prefix is omitted and the CID is used directly.

Single Responsibility Split

The storage pipeline is split across three narrow classes, each owning exactly one concern:

This decomposition means you can swap any piece in isolation — e.g. replace MinioCapacityGuard with a no-op guard in tests, or replace Sha256CidProvider with a Blake3 provider — without touching the orchestration logic.

Config-Only Backend Switch

Consumers depend only on IObjectStorageProvider from the domain project (WI-03), which has zero external NuGet dependencies. Wiring to MinIO, AWS S3, or any S3-compatible backend is done entirely in configuration — no code changes are required to point a deployment at a different endpoint. This makes infrastructure migrations purely operational.

Write-Protect Before Overflow

Before writing any file, the capacity guard queries the MinIO Prometheus metrics endpoint and computes current disk utilization. If utilization equals or exceeds WriteStopThresholdPercent, a StorageCapacityException is thrown immediately, before any bytes are sent to S3. This prevents a storage node from filling to 100% and entering a read-only emergency state.

The Four Work Items

The InfraHub Storage feature is delivered across four .NET projects, each with a clearly scoped responsibility. The table below summarises what lives in each project.

Work Item Project Key Types & Contracts
WI-01 BizFirst.Integration.S3.Domain S3Credential (ServiceURL, AccessKey, SecretKey, Region, ForcePathStyle) — immutable credential record used by the S3 client factory.

Result records: UploadObjectResult, DownloadObjectResult, ExistsObjectResult, StreamObjectResult, CopyObjectResult, DeleteObjectResult, GetManyObjectsResult, and the bucket/folder variants (BucketExists, CreateBucketResult, ListFoldersResult, ListFolderObjectsResult, etc.). All results carry a Success flag and an ErrorMessage so callers never encounter raw S3 exceptions.
WI-02 BizFirst.Integration.S3.Services S3ClientFactory — builds and caches an IAmazonS3 client from an S3Credential. Supports both path-style (MinIO) and virtual-hosted-style (AWS S3) URLs.

IS3ObjectService — object-level operations; includes DownloadStreamAsync (returns a live Stream without buffering the entire body in memory) and ExistsAsync (HEAD request, no data transfer).

IS3BucketService — bucket lifecycle: create, delete, list, check existence.

IS3FolderService — folder (prefix) operations: list, delete by prefix, copy prefix. Depends on WI-01 only.
WI-03 BizFirst.Ai.InfraHub.Storage.Domain IObjectStorageProvider — the only interface consumers depend on; three methods: StoreAsync, GetStreamAsync, ExistsAsync.

StoreOptions — optional per-call overrides (e.g. ForceOverwrite, CustomMetadata).

StorageCapacityException — thrown when disk utilization exceeds the configured threshold.

This project has zero external dependencies. It can be referenced by any project without pulling in S3 SDKs or infrastructure concerns.
WI-04 BizFirst.Ai.InfraHub.Storage.S3.Services ICidProvider / Sha256CidProvider — computes SHA-256 CID from raw bytes.

IStorageCapacityGuard / MinioCapacityGuard — reads the Prometheus cluster metrics endpoint, computes utilization, and enforces the write-stop threshold.

S3ObjectStorageProvider — the concrete implementation of IObjectStorageProvider; orchestrates the full pipeline using WI-01, WI-02, WI-03, and the AI session context for TenantID.

FileStorageDependencyInjection — the AddObjectStorage(IConfiguration) extension method that wires everything into the DI container. This is the only type consumers call at startup.

Project Dependency Graph

The dependency arrows flow in one direction only — from higher-level orchestration down to lower-level contracts. Consumer projects never reference WI-04 directly; they only call AddObjectStorage() once at startup and then depend on IObjectStorageProvider from WI-03.

BizFirst.Integration.S3.Domain (WI-01) └── BizFirst.Integration.S3.Services (WI-02) depends on: WI-01 BizFirst.Ai.InfraHub.Storage.Domain (WI-03) no external dependencies BizFirst.Ai.InfraHub.Storage.S3.Services (WI-04) depends on: WI-01 (S3Credential, request/result records) depends on: WI-02 (IS3ObjectService, S3ClientFactory) depends on: WI-03 (IObjectStorageProvider, StoreOptions, StorageCapacityException) depends on: BizFirst.Ai.AiSession.Domain (IAiSessionContextAccessor → TenantID) Consumer projects depend on: WI-03 (IObjectStorageProvider only) call: services.AddObjectStorage(config) ← from WI-04 (startup only)
Why does this structure matter? By keeping the domain project (WI-03) free of external dependencies, any BizFirst service — including lightweight utilities and library projects — can reference IObjectStorageProvider without pulling in AWS SDK assemblies. The S3 implementation is an infrastructure detail that only enters the process through DI at startup.

The IObjectStorageProvider Contract

The entire InfraHub Storage surface area exposed to application code is just three methods. Every consumer depends on this interface and nothing else from the storage layer.

public interface IObjectStorageProvider
{
    /// <summary>
    /// Stores raw bytes and returns the stable CID for this content.
    /// If identical bytes were stored before, the existing CID is returned immediately
    /// (no second upload). Throws StorageCapacityException when disk is full.
    /// </summary>
    Task<string> StoreAsync(byte[] content, string contentType,
        StoreOptions? options = null, CancellationToken ct = default);

    /// <summary>
    /// Returns a live stream for the object identified by the given CID.
    /// The caller is responsible for disposing the stream.
    /// </summary>
    Task<Stream> GetStreamAsync(string cid,
        CancellationToken ct = default);

    /// <summary>
    /// Returns true if the object identified by the given CID currently exists
    /// in the bucket (HEAD request — no data transfer).
    /// </summary>
    Task<bool> ExistsAsync(string cid,
        CancellationToken ct = default);
}

The interface intentionally exposes streams rather than byte arrays on retrieval. This allows large files (video, CAD drawings, archived datasets) to be forwarded directly to an HTTP response stream or written to disk without ever materialising the entire file in memory.

CID — Content Identity

A CID is the SHA-256 hash of a file's raw bytes, encoded as a 64-character lowercase hexadecimal string. It is computed by Sha256CidProvider using SHA256.HashData(bytes) and Convert.ToHexString(hash).ToLowerInvariant().

Properties

Example

// Input: any byte array
byte[] bytes = File.ReadAllBytes("invoice-2026-001.pdf");

// CID output (64-char lowercase hex)
"3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4"

Storage Key Format

The S3 object key is derived from the CID according to the PrependTenantID configuration setting:

PrependTenantIDS3 Object KeyUse Case
true tenant_42/3a7f2e...d2e4 Multi-tenant SaaS deployments; per-tenant isolation, export, and IAM policies
false 3a7f2e...d2e4 Single-tenant deployments, development environments, or when a separate bucket per tenant is used

The TenantID is resolved at runtime from IAiSessionContextAccessor.CurrentRequestSession.TenantID — the active session context set by the BizFirst platform request pipeline. No tenant ID is passed explicitly by the caller; it flows in automatically through the ambient session context.

Capacity Guard

The MinioCapacityGuard is a proactive safeguard against storage exhaustion. It is invoked at the beginning of every StoreAsync call, before any bytes are transferred.

How It Works

  1. Sends an HTTP GET to the MinIO Prometheus endpoint: {Endpoint}/minio/v2/metrics/cluster
  2. Parses the Prometheus text format, summing all values for:
    • minio_node_disk_used_bytes — total bytes currently in use across all nodes.
    • minio_node_disk_total_bytes — total available bytes across all nodes.
  3. Computes utilization: usedBytes / totalBytes * 100
  4. If utilization >= WriteStopThresholdPercent, throws StorageCapacityException with a message that includes the current utilization percentage and the threshold.
  5. If utilization is below the threshold, returns immediately and the write proceeds.
Prometheus Unavailability If the Prometheus metrics endpoint is unreachable (network error, MinIO version without metrics support, or the endpoint is behind a firewall), MinioCapacityGuard logs a warning at the Warning log level and allows the write to proceed. This is a fail-open posture: storage availability is preferred over false negatives from a monitoring endpoint. Set WriteStopThresholdPercent=0 to disable the guard entirely (the check is skipped without any HTTP call).

Configuration

SettingDefaultMeaning
WriteStopThresholdPercent 90.0 Disk utilization percentage at which writes are blocked. Set to 0 to disable.

SRP Class Decomposition

The three concrete classes that make up the WI-04 service layer each own a single, narrow responsibility. This table maps class to responsibility and DI lifetime.

Class Single Responsibility DI Lifetime Notes
Sha256CidProvider Compute a SHA-256 CID from a raw byte array. No I/O, no configuration. Singleton Stateless; safe to share across all requests and threads. SHA256.HashData is thread-safe.
MinioCapacityGuard Read the MinIO Prometheus metrics endpoint and enforce the write-stop threshold. Scoped Scoped so it participates in the same request lifetime as the session context accessor. Uses IHttpClientFactory internally.
S3ObjectStorageProvider Orchestrate the store / retrieve / exists pipeline, delegating CID computation and capacity checking to the two services above. Scoped Depends on IAiSessionContextAccessor (scoped), IS3ObjectService (scoped), ICidProvider (singleton), and IStorageCapacityGuard (scoped).

Storage Key Strategy

The S3 object key strategy determines how files are organised inside the bucket. The choice is controlled entirely by the PrependTenantID configuration flag.

PrependTenantID = true (Multi-Tenant Mode)

Each file is stored under the prefix tenant_{tenantID}/ where tenantID is the integer tenant identifier from the active session context. Example key:

tenant_42/3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4

PrependTenantID = false (Single-Tenant / Testing Mode)

The CID is used directly as the S3 key, with no prefix. Example key:

3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4

Why Tenant Prefixes?

Deduplication and Tenant Prefixes Because different tenants use different prefixes, the same file content uploaded by tenant 42 and tenant 99 will produce two separate S3 objects (even though the CID portion is identical). Deduplication is scoped per-tenant, not globally. This is intentional: cross-tenant data sharing via S3 key aliases would create an implicit coupling that violates tenant isolation guarantees.

Deduplication Flow

When StoreAsync is called, the following pipeline executes in sequence. The deduplication short-circuit (the ExistsAsync check) means that storing the same file a second time costs only a SHA-256 hash computation and a single S3 HEAD request — no data is transferred.

Caller
StoreAsync(bytes)
Capacity Guard
Check disk %
Sha256CidProvider
Compute CID
ExistsAsync
HEAD request
Exists?
YES
Return CID
No upload
NO
Upload to S3
PutObjectAsync
Return CID
New object

Step-by-Step

  1. Capacity checkMinioCapacityGuard.GuardAsync(ct) queries Prometheus. If utilization >= threshold, throws StorageCapacityException immediately. No further processing.
  2. CID computationSha256CidProvider.Compute(bytes) returns the 64-char hex CID. This is pure CPU work with no I/O.
  3. Key construction — the S3 key is assembled from the CID (and optional tenant prefix).
  4. Existence checkIS3ObjectService.ExistsAsync(bucket, key) issues an S3 HEAD request. This is a single round-trip with no body transfer.
  5. Short-circuit on duplicate — if the object already exists, the CID is returned to the caller immediately. The total cost is: one Prometheus HTTP call + one SHA-256 hash + one S3 HEAD. No upload bandwidth is used.
  6. Upload on new content — if the object does not exist, IS3ObjectService.UploadAsync(bucket, key, bytes, contentType) streams the bytes to S3. On success, the CID is returned.
TOCTOU Note There is a theoretical time-of-check / time-of-use race: two concurrent callers storing the same new file may both pass the ExistsAsync check and both attempt an upload. S3 PUT is idempotent for the same key and content, so the second upload simply overwrites with identical bytes. No data corruption occurs; one upload is wasted. This race is extremely rare in practice and requires no locking mechanism.
Next: Getting Started — store your first file in 5 minutes  ·  Setup & Configuration  ·  Configuration Reference