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
8 changes: 8 additions & 0 deletions backend/src/CodeSpace.Api/Controllers/PacksController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,12 @@ public async Task<IActionResult> Sync([FromRoute] Guid packId, CancellationToken
var result = await _mediator.Send(new SyncPackCommand { PackId = packId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>Add the selected artifacts a Sync discovered to this pack, cloned from the pack's saved source and ref — the body carries only <c>{ "sourcePaths": [...] }</c>, never a URL. The route's id is authoritative.</summary>
[HttpPost("{packId:guid}/import")]
public async Task<IActionResult> Import([FromRoute] Guid packId, [FromBody] ImportPackArtifactsCommand command, CancellationToken cancellationToken)
{
var result = await _mediator.Send(command with { PackId = packId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using CodeSpace.Core.Services.Agents;
using CodeSpace.Messages.Commands.Agents;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Agents;

/// <summary>Thin dispatcher (Rule 16) — the production caller of <see cref="IPackCloneUrlBackfillService.BackfillAsync"/>.</summary>
public sealed class BackfillPackCloneUrlsCommandHandler : IRequestHandler<BackfillPackCloneUrlsCommand, int>
{
private readonly IPackCloneUrlBackfillService _backfill;

public BackfillPackCloneUrlsCommandHandler(IPackCloneUrlBackfillService backfill)
{
_backfill = backfill;
}

public async Task<int> Handle(BackfillPackCloneUrlsCommand request, CancellationToken cancellationToken)
{
return await _backfill.BackfillAsync(request.BatchSize, cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using CodeSpace.Core.Services.Agents;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Commands.Agents;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Agents;

public sealed class ImportPackArtifactsCommandHandler : IRequestHandler<ImportPackArtifactsCommand, PackImportResult>
{
private readonly IPackImportService _service;
private readonly ICurrentTeam _currentTeam;
private readonly ICurrentUser _currentUser;

public ImportPackArtifactsCommandHandler(IPackImportService service, ICurrentTeam currentTeam, ICurrentUser currentUser)
{
_service = service;
_currentTeam = currentTeam;
_currentUser = currentUser;
}

public Task<PackImportResult> Handle(ImportPackArtifactsCommand request, CancellationToken cancellationToken) =>
_service.ImportFromPackAsync(_currentTeam.Id!.Value, request.PackId, request.SourcePaths, _currentUser.Id!.Value, cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using CodeSpace.Messages.Commands.Agents;
using MediatR;

namespace CodeSpace.Core.Jobs.RecurringJobs;

/// <summary>Every ten minutes: seal the clone URL of any pack row that still holds a pasted token in plaintext — the rows imported before the seal existed, and any an older pod writes during a rolling deploy (thin Rule-14 dispatcher). A sealed row is no longer a candidate, so an idle tick is one small read.</summary>
public sealed class PackCloneUrlBackfillRecurringJob : IRecurringJob
{
private readonly IMediator _mediator;

public PackCloneUrlBackfillRecurringJob(IMediator mediator) { _mediator = mediator; }

public string JobId => nameof(PackCloneUrlBackfillRecurringJob);
public string CronExpression => "*/10 * * * *";

public async Task Execute() => await _mediator.Send(new BackfillPackCloneUrlsCommand()).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- 0241_pack_sealed_clone_url.sql
--
-- A pack imported from a pasted git URL that embedded a token stored that URL verbatim in pack.url, which every team
-- member (Viewers included) reads and the Library renders as a link. From now on pack.url holds the URL with its
-- userinfo removed, and the URL exactly as cloned is sealed with the platform's credential encryptor
-- (IPayloadEncryptor) in encrypted_clone_url, which Sync and import-from-pack decrypt just in time and nothing returns.
--
-- SQL cannot run that encryptor, so existing rows are sealed by the application: PackCloneUrlBackfillRecurringJob
-- rewrites each one with a conditional UPDATE, and a re-import of the same repository seals the row it lands in. Until
-- then, Sync keeps working on an unsealed row (its url still carries the token it needs), and the read model strips
-- userinfo on the way out.
--
-- duplicate_of_pack_id marks a legacy fork: the same repository imported once with a token and once without (or with
-- two tokens) became two packs, whose urls collide once both are credential-free. The clean pack (else the oldest)
-- holds the source identity; the other keeps its artifacts and its own sealed source, points at the holder, and
-- leaves the unique index — so the index is recreated with that predicate. Its predicate covers a subset of the rows the old
-- index did, so recreating it cannot fail on existing data.
--
-- Additive: two nullable columns. Idempotent (IF NOT EXISTS / IF EXISTS). An older pod ignores the columns but not what
-- the new code writes into the rows: until the rollout completes it cannot Sync a sealed private pack (it clones
-- pack.url, which no longer carries the token), and its import-url fails ("more than one element") for a repository whose
-- legacy fork has become a holder plus a duplicate. In an Api/Worker split, roll the Api pods out before the Worker pods,
-- which run the backfill.

ALTER TABLE pack ADD COLUMN IF NOT EXISTS encrypted_clone_url TEXT NULL;

ALTER TABLE pack ADD COLUMN IF NOT EXISTS duplicate_of_pack_id UUID NULL REFERENCES pack(id);

DROP INDEX IF EXISTS uq_pack_team_source;

CREATE UNIQUE INDEX IF NOT EXISTS uq_pack_team_source
ON pack(team_id, url, COALESCE(subpath, '')) WHERE deleted_date IS NULL AND url IS NOT NULL AND duplicate_of_pack_id IS NULL;

COMMENT ON COLUMN pack.encrypted_clone_url IS
'The URL the pack''s last successful import cloned, sealed with IPayloadEncryptor (purpose CodeSpace.Credentials.v1); '
'set only when that URL carried userinfo. pack.url is the same URL without it. Never returned by any API.';

COMMENT ON COLUMN pack.duplicate_of_pack_id IS
'The pack holding this pack''s source identity when a legacy import forked one repository into two packs. '
'A duplicate keeps syncing from its own sealed source and is excluded from uq_pack_team_source.';
8 changes: 7 additions & 1 deletion backend/src/CodeSpace.Core/Persistence/Entities/Pack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ public class Pack : IEntity<Guid>, IAuditable
/// <summary>Human-readable library name (e.g. the repo name) — what the UI groups skills under.</summary>
public string Name { get; set; } = default!;

/// <summary>The source location: <c>owner/repo</c> for <see cref="PackKind.Github"/> or a clone URL for <see cref="PackKind.GitUrl"/>. NULL for the <see cref="PackKind.Custom"/> pack.</summary>
/// <summary>The source location: <c>owner/repo</c> for <see cref="PackKind.Github"/> or a clone URL for <see cref="PackKind.GitUrl"/>. NULL for the <see cref="PackKind.Custom"/> pack. Never carries a credential: it is shown to every team member and is the pack's identity, so a pasted URL's userinfo lives sealed in <see cref="EncryptedCloneUrl"/>.</summary>
public string? Url { get; set; }

/// <summary>The URL the last successful import cloned, sealed with <c>IPayloadEncryptor</c> — present only when that URL carried userinfo (a pasted token). Sync and import-from-pack clone from it; nothing returns it. Read and written only through <c>IPackCloneUrlProtector</c>.</summary>
public string? EncryptedCloneUrl { get; set; }

/// <summary>The pack that holds this pack's source identity, when an earlier import forked the same repository into two packs (one with a token and one without, or with two tokens). A duplicate keeps syncing from its own source; a new import resolves to the holder. NULL for every other pack.</summary>
public Guid? DuplicateOfPackId { get; set; }

/// <summary>The git ref synced (branch / tag / commit). NULL → the source's default branch.</summary>
public string? Reference { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace CodeSpace.Core.Services.Agents;

/// <summary>
/// Seals the clone URL of pack rows that still hold a credential in plaintext: every row imported before the pack kept
/// its pasted token sealed, soft-deleted ones included, and any an older pod writes during a rolling deploy. On a pod
/// running this code each row keeps syncing throughout — before its seal it clones from its URL, after it from the
/// sealed copy.
///
/// <para>A pod that predates the seal cannot read it. Until the rollout completes, such a pod fails to Sync a sealed
/// private pack (it clones <c>pack.url</c>, which no longer carries the token), and its import-url fails for a
/// repository whose legacy fork this pass turned into a holder plus a duplicate (two active rows under one URL). This
/// job runs only where Hangfire processes jobs, so in an Api/Worker split, rolling the Api pods out before the Worker
/// pods keeps the backfill from sealing anything an old Api pod can still serve.</para>
/// </summary>
public interface IPackCloneUrlBackfillService
{
/// <summary>Seal up to <paramref name="batchSize"/> credential-carrying rows. Idempotent and safe on several workers at once: a sealed row is no longer a candidate, and each write is conditional on the row still holding the URL it read. Returns how many rows this pass sealed.</summary>
Task<int> BackfillAsync(int batchSize, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using CodeSpace.Core.Persistence.Entities;

namespace CodeSpace.Core.Services.Agents;

/// <summary>
/// Splits a pack's clone URL into the credential-free URL the pack stores, shows and is identified by, and the sealed
/// URL it clones from. A pasted git URL may embed a token in its userinfo; that URL is what the import clones, and it
/// is the only thing that can clone a private pack again — but <c>pack.url</c> reaches every team member. The split
/// keeps the clone lossless (Sync clones the exact string the import cloned) without the token ever being stored,
/// returned or rendered in plaintext.
/// </summary>
public interface IPackCloneUrlProtector
{
/// <summary>The URL a pack stores for <paramref name="cloneUrl"/> (its userinfo removed; byte-identical when it carries none) and, only when it carried userinfo, the whole URL sealed.</summary>
(string Url, string? EncryptedCloneUrl) Seal(string cloneUrl);

/// <summary>The URL to clone <paramref name="pack"/> from: its sealed source decrypted, or its URL when it has none. Throws <see cref="PackImportException"/>, naming neither URL nor ciphertext, when the sealed source can no longer be read.</summary>
string CloneUrlOf(Pack pack);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ public interface IPackImportService

/// <summary>Re-pull the pack <paramref name="packId"/> from its saved source: refresh every already-imported artifact in place (kept handles) and return what changed plus the discovered-but-not-imported artifacts as a preview to add.</summary>
Task<PackSyncResult> SyncAsync(Guid teamId, Guid packId, Guid actorUserId, CancellationToken cancellationToken);

/// <summary>Re-clone the pack <paramref name="packId"/> from its saved source at its saved ref and persist exactly the chosen <paramref name="sourcePaths"/> into THAT pack — the add-new step after a Sync. The pack is never resolved again by URL, so a private pack (whose stored URL cannot clone) and a legacy duplicate both import into themselves. Returns a per-path outcome.</summary>
Task<PackImportResult> ImportFromPackAsync(Guid teamId, Guid packId, IReadOnlyList<string> sourcePaths, Guid actorUserId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Agents;

/// <summary>
/// The bounded seal pass behind <see cref="IPackCloneUrlBackfillService"/>. A candidate is a row whose URL carries
/// userinfo, judged by the writer's own rule (<see cref="PackCloneUrlProtector.CarriesCredential"/>) in memory BEFORE
/// the batch is cut, so a row with an '@' only in its path never crowds out a real one. A sealed row's URL no longer
/// carries userinfo, so it leaves the set: the pass is self-terminating.
///
/// <para>Each row is written by one UPDATE conditional on its id AND the URL the pass read, so a second worker sealing
/// the same row matches nothing and the ciphertext is written once. When sealing makes an active row's URL equal to
/// another active pack's (a legacy fork of one repository), the row becomes that pack's duplicate; when two rows of one
/// group race to hold it, the unique index refuses one, and the next pass finds the holder. No lock is needed.</para>
///
/// <para>Per-row try/catch: a failing row stays a candidate and never aborts the pass. The log names the pack and the
/// team, never the URL.</para>
/// </summary>
public sealed class PackCloneUrlBackfillService : IPackCloneUrlBackfillService, IScopedDependency
{
private readonly CodeSpaceDbContext _db;
private readonly IPackCloneUrlProtector _protector;
private readonly ILogger<PackCloneUrlBackfillService> _logger;

public PackCloneUrlBackfillService(CodeSpaceDbContext db, IPackCloneUrlProtector protector, ILogger<PackCloneUrlBackfillService> logger)
{
_db = db;
_protector = protector;
_logger = logger;
}

public async Task<int> BackfillAsync(int batchSize, CancellationToken cancellationToken)
{
var candidates = await LoadCandidatesAsync(batchSize, cancellationToken).ConfigureAwait(false);

var sealedCount = 0;

foreach (var candidate in candidates)
{
try
{
if (await SealAsync(candidate, cancellationToken).ConfigureAwait(false)) sealedCount++;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning("Pack clone-URL backfill failed for pack {PackId} in team {TeamId} ({ExceptionType}); the pass continues — the pack stays a candidate", candidate.Id, candidate.TeamId, ex.GetType().Name);
}
}

return sealedCount;
}

/// <summary>The oldest rows, active or soft-deleted, whose URL carries a credential. The '@' filter is a sound superset (userinfo needs one) that keeps the read small; the real rule runs in memory before the batch is cut.</summary>
private async Task<IReadOnlyList<Candidate>> LoadCandidatesAsync(int batchSize, CancellationToken cancellationToken)
{
var rows = await _db.Pack.AsNoTracking()
.Where(p => p.Url != null && p.Url.Contains("@"))
.OrderBy(p => p.CreatedDate).ThenBy(p => p.Id)
.Select(p => new Candidate(p.Id, p.TeamId, p.Url!, p.Subpath, p.DeletedDate == null))
.ToListAsync(cancellationToken).ConfigureAwait(false);

return rows.Where(r => PackCloneUrlProtector.CarriesCredential(r.Url)).Take(batchSize).ToList();
}

/// <summary>Rewrite one row to its credential-free URL plus its sealed original, marking it a duplicate when another active pack already holds that URL. False when another worker sealed it first.</summary>
private async Task<bool> SealAsync(Candidate candidate, CancellationToken cancellationToken)
{
var source = _protector.Seal(candidate.Url);

var holderId = candidate.IsActive ? await FindHolderAsync(candidate, source.Url, cancellationToken).ConfigureAwait(false) : null;

var written = await _db.Pack
.Where(p => p.Id == candidate.Id && p.Url == candidate.Url)
.ExecuteUpdateAsync(set => set.SetProperty(p => p.Url, source.Url).SetProperty(p => p.EncryptedCloneUrl, source.EncryptedCloneUrl).SetProperty(p => p.DuplicateOfPackId, holderId), cancellationToken).ConfigureAwait(false);

return written == 1;
}

/// <summary>The active pack that already holds <paramref name="cleanUrl"/> as its source identity in the candidate's team and subpath — the one a sealed duplicate points at.</summary>
private async Task<Guid?> FindHolderAsync(Candidate candidate, string cleanUrl, CancellationToken cancellationToken) =>
await _db.Pack.AsNoTracking()
.Where(p => p.TeamId == candidate.TeamId && p.Url == cleanUrl && (p.Subpath ?? "") == (candidate.Subpath ?? "") && p.DuplicateOfPackId == null && p.DeletedDate == null && p.Id != candidate.Id)
.OrderBy(p => p.CreatedDate).ThenBy(p => p.Id)
.Select(p => (Guid?)p.Id)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);

private sealed record Candidate(Guid Id, Guid TeamId, string Url, string? Subpath, bool IsActive);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Security.Cryptography;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Agents.Workspace;
using CodeSpace.Core.Services.Credentials;

namespace CodeSpace.Core.Services.Agents;

/// <summary>
/// <see cref="IPackCloneUrlProtector"/> over the platform's credential encryptor (<see cref="IPayloadEncryptor"/>, whose
/// key ring every pod shares) — the same storage a model API key or a webhook secret has. One rule decides whether a
/// URL carries a credential: removing its userinfo changes it. That covers a token pasted as the password and one
/// pasted as the user alone.
///
/// <para>The WHOLE URL is sealed, not just its userinfo: removing the userinfo also normalizes the URL (host case,
/// escaping), so recomposing it would clone a different string than the import cloned.</para>
/// </summary>
public sealed class PackCloneUrlProtector : IPackCloneUrlProtector, ISingletonDependency
{
private const string UnreadableSourceMessage = "This pack's saved source credential can no longer be read; import it again from a URL with a current token.";

private readonly IPayloadEncryptor _encryptor;

public PackCloneUrlProtector(IPayloadEncryptor encryptor) { _encryptor = encryptor; }

public (string Url, string? EncryptedCloneUrl) Seal(string cloneUrl)
{
var url = WithoutCredential(cloneUrl);

return (url, url == cloneUrl ? null : _encryptor.Encrypt(cloneUrl));
}

public string CloneUrlOf(Pack pack)
{
if (pack.EncryptedCloneUrl is null) return pack.Url!;

try
{
return _encryptor.Decrypt(pack.EncryptedCloneUrl);
}
catch (CryptographicException)
{
// The inner exception is dropped on purpose: it can quote the payload, and this message reaches the API.
throw new PackImportException(UnreadableSourceMessage);
}
}

/// <summary><paramref name="url"/> with its userinfo removed; unchanged when it carries none (or is not an absolute URL). Pure + internal so the read model and the backfill share the writer's rule.</summary>
internal static string WithoutCredential(string url) => RemoteTipResolver.SanitizeUrl(url);

/// <summary>True when <paramref name="url"/> carries userinfo — the backfill's candidate test. Pure + internal so it is unit-pinned against <see cref="Seal"/>.</summary>
internal static bool CarriesCredential(string url) => WithoutCredential(url) != url;
}
Loading
Loading