275 lines
8.4 KiB
Markdown
275 lines
8.4 KiB
Markdown
# Sync Ledger Schema
|
|
|
|
Sprint: `SPRINT_8200_0014_0001_DB_sync_ledger_schema`
|
|
Migration: `008_sync_ledger.sql`
|
|
Working Directory: `src/Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/`
|
|
|
|
## Overview
|
|
|
|
The sync ledger schema provides federation cursor tracking for multi-site vulnerability advisory synchronization. It enables air-gapped and distributed deployments to:
|
|
|
|
1. Track sync state per remote site using monotonic cursors
|
|
2. Prevent duplicate bundle imports via hash-based deduplication
|
|
3. Enforce per-site policies (source filtering, size limits, signature requirements)
|
|
|
|
This is critical infrastructure for the Concelier module's federation capabilities.
|
|
|
|
## Tables
|
|
|
|
### `vuln.sync_ledger`
|
|
|
|
Tracks federation sync state per remote site. Each record represents a successfully imported bundle.
|
|
|
|
| Column | Type | Description |
|
|
|--------|------|-------------|
|
|
| `id` | UUID | Primary key (auto-generated) |
|
|
| `site_id` | TEXT | Remote site identifier (e.g., `site-us-west`, `airgap-dc2`) |
|
|
| `cursor` | TEXT | Position marker for incremental sync (ISO8601#sequence format) |
|
|
| `bundle_hash` | TEXT | SHA256 hash of imported bundle (for deduplication) |
|
|
| `items_count` | INT | Number of advisory items in the bundle |
|
|
| `signed_at` | TIMESTAMPTZ | Timestamp when bundle was signed by remote site |
|
|
| `imported_at` | TIMESTAMPTZ | Timestamp when bundle was imported locally (auto-generated) |
|
|
|
|
**Constraints:**
|
|
- `uq_sync_ledger_site_cursor`: Unique constraint on `(site_id, cursor)` - prevents duplicate cursor positions
|
|
- `uq_sync_ledger_bundle`: Unique constraint on `bundle_hash` - prevents reimporting same bundle
|
|
|
|
### `vuln.site_policy`
|
|
|
|
Per-site federation governance policies. Controls what data can be imported from each remote site.
|
|
|
|
| Column | Type | Description |
|
|
|--------|------|-------------|
|
|
| `id` | UUID | Primary key (auto-generated) |
|
|
| `site_id` | TEXT | Site identifier (unique) |
|
|
| `display_name` | TEXT | Human-readable site name |
|
|
| `allowed_sources` | TEXT[] | Source allow list (empty = allow all, supports wildcards) |
|
|
| `denied_sources` | TEXT[] | Source deny list (takes precedence over allow list) |
|
|
| `max_bundle_size_mb` | INT | Maximum bundle size in MB (default: 100) |
|
|
| `max_items_per_bundle` | INT | Maximum items per bundle (default: 10000) |
|
|
| `require_signature` | BOOLEAN | Require cryptographic signature on bundles (default: TRUE) |
|
|
| `allowed_signers` | TEXT[] | Allowed signer key IDs or issuer names |
|
|
| `enabled` | BOOLEAN | Policy enabled status (default: TRUE) |
|
|
| `created_at` | TIMESTAMPTZ | Record creation time |
|
|
| `updated_at` | TIMESTAMPTZ | Last modification time (auto-updated via trigger) |
|
|
|
|
## Indexes
|
|
|
|
| Index | Columns | Purpose |
|
|
|-------|---------|---------|
|
|
| `idx_sync_ledger_site` | `site_id` | Fast site-based lookups |
|
|
| `idx_sync_ledger_site_time` | `site_id, signed_at DESC` | Get latest entries per site |
|
|
| `idx_site_policy_enabled` | `enabled` (partial, WHERE enabled = TRUE) | Filter active policies |
|
|
|
|
## Cursor Format
|
|
|
|
Cursors use a combined timestamp + sequence format for total ordering:
|
|
|
|
```
|
|
2025-01-15T10:30:00.000Z#0042
|
|
```
|
|
|
|
**Components:**
|
|
- ISO8601 timestamp with timezone (UTC)
|
|
- `#` separator
|
|
- 4-digit sequence number (allows multiple bundles per timestamp)
|
|
|
|
**Ordering rules:**
|
|
1. Compare timestamps first
|
|
2. If equal, compare sequence numbers
|
|
3. Higher values indicate later positions
|
|
|
|
### CursorFormat Utility
|
|
|
|
```csharp
|
|
// Create cursor
|
|
string cursor = CursorFormat.Create(DateTimeOffset.UtcNow, sequence: 0);
|
|
// Result: "2025-01-15T10:30:00.0000000+00:00#0000"
|
|
|
|
// Parse cursor
|
|
var (timestamp, sequence) = CursorFormat.Parse(cursor);
|
|
|
|
// Compare cursors
|
|
bool isNewer = CursorFormat.IsAfter(cursor1, cursor2);
|
|
```
|
|
|
|
## Repository Operations
|
|
|
|
### ISyncLedgerRepository Interface
|
|
|
|
```csharp
|
|
public interface ISyncLedgerRepository
|
|
{
|
|
// Ledger CRUD
|
|
Task<SyncLedgerEntity?> GetLatestAsync(string siteId, CancellationToken ct = default);
|
|
Task<IReadOnlyList<SyncLedgerEntity>> GetHistoryAsync(string siteId, int limit = 10, CancellationToken ct = default);
|
|
Task<SyncLedgerEntity?> GetByBundleHashAsync(string bundleHash, CancellationToken ct = default);
|
|
Task<Guid> InsertAsync(SyncLedgerEntity entry, CancellationToken ct = default);
|
|
|
|
// Cursor operations
|
|
Task<string?> GetCursorAsync(string siteId, CancellationToken ct = default);
|
|
Task AdvanceCursorAsync(string siteId, string newCursor, string bundleHash,
|
|
int itemsCount, DateTimeOffset signedAt, CancellationToken ct = default);
|
|
Task<bool> IsCursorConflictAsync(string siteId, string cursor, CancellationToken ct = default);
|
|
|
|
// Policy operations
|
|
Task<SitePolicyEntity?> GetPolicyAsync(string siteId, CancellationToken ct = default);
|
|
Task UpsertPolicyAsync(SitePolicyEntity policy, CancellationToken ct = default);
|
|
Task<IReadOnlyList<SitePolicyEntity>> GetAllPoliciesAsync(bool enabledOnly = true, CancellationToken ct = default);
|
|
|
|
// Statistics
|
|
Task<SyncStatistics> GetStatisticsAsync(CancellationToken ct = default);
|
|
}
|
|
```
|
|
|
|
## Policy Enforcement
|
|
|
|
The `SitePolicyEnforcementService` validates imports against site policies.
|
|
|
|
### Source Validation
|
|
|
|
Sources are validated against allow/deny lists with wildcard support:
|
|
|
|
```csharp
|
|
// Pattern matching
|
|
"github.com/*" // Matches github.com/advisories, github.com/osv
|
|
"*.redhat.com" // Matches access.redhat.com, bugzilla.redhat.com
|
|
"nvd.nist.gov" // Exact match only
|
|
```
|
|
|
|
**Evaluation order:**
|
|
1. Check deny list first (if matched, reject)
|
|
2. If allow list is empty, allow all
|
|
3. If allow list has entries, source must match at least one
|
|
|
|
### Bundle Size Validation
|
|
|
|
```csharp
|
|
var result = await policyService.ValidateBundleSizeAsync(
|
|
siteId: "site-us-west",
|
|
bundleSizeMb: 50,
|
|
itemsCount: 5000,
|
|
ct: cancellationToken);
|
|
|
|
if (!result.IsValid)
|
|
{
|
|
// result.SizeExceedsLimit or result.ItemsExceedLimit
|
|
}
|
|
```
|
|
|
|
### Budget Tracking
|
|
|
|
```csharp
|
|
var budget = await policyService.GetBudgetInfoAsync("site-us-west", ct);
|
|
// Returns: TotalImported, ItemsImported, LastImportAt, RemainingBudgetMb
|
|
```
|
|
|
|
## Usage Examples
|
|
|
|
### Check for duplicate bundle
|
|
|
|
```sql
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM vuln.sync_ledger
|
|
WHERE bundle_hash = :hash
|
|
) AS already_imported;
|
|
```
|
|
|
|
### Get latest cursor for incremental sync
|
|
|
|
```sql
|
|
SELECT cursor
|
|
FROM vuln.sync_ledger
|
|
WHERE site_id = :site_id
|
|
ORDER BY signed_at DESC
|
|
LIMIT 1;
|
|
```
|
|
|
|
### Import history with pagination
|
|
|
|
```sql
|
|
SELECT id, cursor, bundle_hash, items_count, signed_at, imported_at
|
|
FROM vuln.sync_ledger
|
|
WHERE site_id = :site_id
|
|
ORDER BY signed_at DESC
|
|
LIMIT :limit OFFSET :offset;
|
|
```
|
|
|
|
### Get active site policies
|
|
|
|
```sql
|
|
SELECT site_id, display_name, allowed_sources, denied_sources,
|
|
max_bundle_size_mb, max_items_per_bundle
|
|
FROM vuln.site_policy
|
|
WHERE enabled = TRUE;
|
|
```
|
|
|
|
### Site sync statistics
|
|
|
|
```sql
|
|
SELECT
|
|
site_id,
|
|
COUNT(*) as bundle_count,
|
|
SUM(items_count) as total_items,
|
|
MAX(signed_at) as latest_bundle,
|
|
MIN(imported_at) as first_import
|
|
FROM vuln.sync_ledger
|
|
GROUP BY site_id;
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Duplicate Bundle Import
|
|
|
|
If a bundle with the same hash is imported twice, the unique constraint prevents insertion. The repository returns the existing entry ID:
|
|
|
|
```csharp
|
|
var existingEntry = await repository.GetByBundleHashAsync(bundleHash, ct);
|
|
if (existingEntry != null)
|
|
{
|
|
// Bundle already imported, skip
|
|
return existingEntry.Id;
|
|
}
|
|
```
|
|
|
|
### Cursor Conflict Detection
|
|
|
|
Out-of-order imports are detected via cursor comparison:
|
|
|
|
```csharp
|
|
bool hasConflict = await repository.IsCursorConflictAsync(siteId, newCursor, ct);
|
|
if (hasConflict)
|
|
{
|
|
// newCursor is not newer than current position
|
|
throw new CursorConflictException(...);
|
|
}
|
|
```
|
|
|
|
## Migration
|
|
|
|
Migration file: `src/Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/Migrations/008_sync_ledger.sql`
|
|
|
|
**Prerequisites:**
|
|
- `vuln` schema must exist
|
|
- `vuln.update_timestamp()` trigger function must exist (from canonical schema migration)
|
|
|
|
Apply with:
|
|
```bash
|
|
psql -d stellaops -f 008_sync_ledger.sql
|
|
```
|
|
|
|
Rollback (if needed):
|
|
```sql
|
|
DROP TRIGGER IF EXISTS trg_site_policy_updated ON vuln.site_policy;
|
|
DROP TABLE IF EXISTS vuln.site_policy;
|
|
DROP TABLE IF EXISTS vuln.sync_ledger;
|
|
```
|
|
|
|
## Related
|
|
|
|
- [Database Specification](../SPECIFICATION.md)
|
|
- [Concelier Architecture](../../modules/concelier/architecture.md)
|
|
- [Federation Export Sprint](../../implplan/SPRINT_8200_0014_0002_federation_export.md)
|
|
- [Federation Import Sprint](../../implplan/SPRINT_8200_0014_0003_federation_import.md)
|
|
- [Air-Gap Operation Guide](../../24_OFFLINE_KIT.md)
|