fad476f115
Skills move from a global Git-only registry to a per-company library that orgs author and
version in-app — Git stays as the shared *starter* library.
Domain & persistence:
- Skill gains OrganizationId (null = shared builtin, visible to every org), Origin
(Builtin | Authored | Installed), AuthoredByMemberId. Identity is now
(OrganizationId, SkillKey, Version); the unique index uses NULLS NOT DISTINCT so builtins
stay unique by key+version while each org gets its own namespace (and can fork a builtin).
AddSkillOwnership migration backfills existing rows as Builtin.
- Owned GoldenExample rows are cloned in Skill.Index so a fork can't re-parent the source's
tracked entities.
Authoring (tenant, dynamic):
- POST /api/skills/authored — structured fields → same indexer pipeline (embedding +
publish gate apply identically), tagged org + author. POST /api/skills/{key}/fork copies a
builtin/global skill into your org as an editable Authored draft. List/Get are org-scoped
(your org + shared builtins). New Capability.ManageSkills (Owner + TeamOwner), audited.
- GET /api/skills/marketplace: read-only seam listing public skills across orgs (install is
the next step).
Security (from adversarial review — two confirmed criticals):
- Managing shared builtins is an operator action, not a tenant one. /index (posts arbitrary
content as a global builtin) and /sync (re-indexes the shared library) now require a
platform admin key (X-Skills-Admin-Key, fixed-time compare, fail-closed when unset) via
SkillAdminOptions — previously any authenticated user of any org could inject/poison global
skills. New test asserts an authenticated Owner without the key gets 403 on both.
UI: new /skills library page — browse shared + org skills grouped by key with their versions,
create / new-version / fork, golden-test editor + body, Draft/Published badge and the
publish-gate hint (needs roles + ≥1 golden test).
Verified: ArchitectureTests 8/8, IntegrationTests 46/46 (new SkillLibraryTests: org
isolation, version coexistence, fork, publish gate, Member 403, admin-gate 403), client build
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
11 KiB
C#
283 lines
11 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using TeamUp.Modules.Skills.Domain;
|
|
using TeamUp.Modules.Skills.Indexing;
|
|
using TeamUp.Modules.Skills.Persistence;
|
|
using TeamUp.Modules.Skills.Sync;
|
|
using TeamUp.SharedKernel.Access;
|
|
using TeamUp.SharedKernel.Auditing;
|
|
using TeamUp.SharedKernel.Modularity;
|
|
|
|
namespace TeamUp.Modules.Skills.Endpoints;
|
|
|
|
internal static class SkillsEndpoints
|
|
{
|
|
public static void Map(IEndpointRouteBuilder endpoints)
|
|
{
|
|
var group = endpoints.MapGroup("/api/skills").WithTags("Skills");
|
|
|
|
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
|
|
group.MapGet("/", ListSkills).RequireAuthorization();
|
|
group.MapGet("/marketplace", Marketplace).RequireAuthorization();
|
|
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
|
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
|
|
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
|
|
group.MapPost("/index", IndexSkill).RequireAuthorization();
|
|
group.MapPost("/sync", Sync).RequireAuthorization();
|
|
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
|
|
}
|
|
|
|
// Re-syncing the shared builtin library is an operator action, not a tenant one.
|
|
private static async Task<IResult> Sync(
|
|
HttpContext http, IOptions<SkillAdminOptions> admin, SkillSyncService sync, CancellationToken ct)
|
|
{
|
|
if (!IsPlatformAdmin(http, admin))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
return Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
|
}
|
|
|
|
// Gitea push webhook → re-sync the source. Re-reads only the trusted Git source (no caller
|
|
// content). Signature verification + changed-file-only sync via the job queue land later.
|
|
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
|
|
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
|
|
|
/// <summary>
|
|
/// Builtins (null-org, all-tenant-visible) may only be managed by a platform operator holding
|
|
/// the configured admin key. Fails closed when no key is configured.
|
|
/// </summary>
|
|
private static bool IsPlatformAdmin(HttpContext http, IOptions<SkillAdminOptions> admin)
|
|
{
|
|
var configured = admin.Value.AdminKey;
|
|
if (string.IsNullOrEmpty(configured) ||
|
|
!http.Request.Headers.TryGetValue("X-Skills-Admin-Key", out var provided))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(provided.ToString()), Encoding.UTF8.GetBytes(configured));
|
|
}
|
|
|
|
private static async Task<IResult> ListSkills(
|
|
Guid? organizationId, string? role, string? visibility,
|
|
IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
|
|
{
|
|
// The library a company sees = the shared builtin starter skills (null org) + its own.
|
|
IQueryable<Skill> query = db.Skills.Where(s => s.OrganizationId == null);
|
|
if (organizationId is { } orgId)
|
|
{
|
|
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
query = db.Skills.Where(s => s.OrganizationId == null || s.OrganizationId == orgId);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(role))
|
|
{
|
|
query = query.Where(s => s.Roles.Contains(role));
|
|
}
|
|
|
|
if (Enum.TryParse<SkillVisibility>(visibility, ignoreCase: true, out var vis))
|
|
{
|
|
query = query.Where(s => s.Visibility == vis);
|
|
}
|
|
|
|
var skills = await query
|
|
.OrderBy(s => s.SkillKey)
|
|
.ThenByDescending(s => s.Version)
|
|
.ToListAsync(ct);
|
|
|
|
return Results.Ok(skills.Select(ToSummary).ToList());
|
|
}
|
|
|
|
// Marketplace seam (read-only groundwork): publicly-shared, org-authored skills from any org.
|
|
// Publishing controls and install-into-your-org land in the next step.
|
|
private static async Task<IResult> Marketplace(SkillsDbContext db, CancellationToken ct)
|
|
{
|
|
var listed = await db.Skills
|
|
.Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public)
|
|
.OrderBy(s => s.SkillKey)
|
|
.ThenByDescending(s => s.Version)
|
|
.ToListAsync(ct);
|
|
return Results.Ok(listed.Select(ToSummary).ToList());
|
|
}
|
|
|
|
private static async Task<IResult> GetSkill(
|
|
string key, Guid? organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
|
|
{
|
|
if (organizationId is { } orgId && !permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
var versions = await db.Skills
|
|
.Where(s => s.SkillKey == key && (s.OrganizationId == null || s.OrganizationId == organizationId))
|
|
.OrderByDescending(s => s.OrganizationId != null) // org's own first, then builtins
|
|
.ThenByDescending(s => s.Version)
|
|
.ToListAsync(ct);
|
|
|
|
return versions.Count == 0
|
|
? Results.NotFound()
|
|
: Results.Ok(versions.Select(ToDetail).ToList());
|
|
}
|
|
|
|
private static async Task<IResult> AuthorSkill(
|
|
AuthorSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
|
IAuditLog audit, SkillIndexer indexer, CancellationToken ct)
|
|
{
|
|
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.SkillKey) ||
|
|
string.IsNullOrWhiteSpace(request.Name) ||
|
|
string.IsNullOrWhiteSpace(request.Version) ||
|
|
string.IsNullOrWhiteSpace(request.Body))
|
|
{
|
|
return Results.BadRequest("skillKey, name, version, and body are required.");
|
|
}
|
|
|
|
var manifest = ToManifest(request);
|
|
var skill = await indexer.IndexAsync(
|
|
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
|
|
|
await audit.WriteAsync(
|
|
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
|
return Results.Ok(ToDetail(skill));
|
|
}
|
|
|
|
private static async Task<IResult> ForkSkill(
|
|
string key, ForkSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
|
IAuditLog audit, SkillsDbContext db, SkillIndexer indexer, CancellationToken ct)
|
|
{
|
|
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
// Fork a builtin (or the org's own) version into an editable, org-owned Authored copy.
|
|
var source = await db.Skills.FirstOrDefaultAsync(
|
|
s => s.SkillKey == key && s.Version == request.Version
|
|
&& (s.OrganizationId == null || s.OrganizationId == request.OrganizationId),
|
|
ct);
|
|
if (source is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var manifest = ToManifest(source);
|
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
{
|
|
manifest.Name = request.Name.Trim();
|
|
}
|
|
|
|
var skill = await indexer.IndexAsync(
|
|
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
|
|
|
await audit.WriteAsync(
|
|
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
|
return Results.Ok(ToDetail(skill));
|
|
}
|
|
|
|
// Posts raw content as a shared builtin → operator-only (otherwise any tenant could inject a
|
|
// global skill). Tenants author org-owned skills via /authored instead.
|
|
private static async Task<IResult> IndexSkill(
|
|
HttpContext http, IOptions<SkillAdminOptions> admin, IndexRequest request, SkillIndexer indexer, CancellationToken ct)
|
|
{
|
|
if (!IsPlatformAdmin(http, admin))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Content))
|
|
{
|
|
return Results.BadRequest("content is required.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var skill = await indexer.IndexAsync(
|
|
request.Content, request.SourceRepo, request.SourcePath, request.SourceCommit, ct);
|
|
return Results.Ok(ToDetail(skill));
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
return Results.BadRequest(ex.Message);
|
|
}
|
|
}
|
|
|
|
private static SkillManifest ToManifest(AuthorSkillRequest request) => new()
|
|
{
|
|
Id = request.SkillKey.Trim(),
|
|
Name = request.Name.Trim(),
|
|
Version = request.Version.Trim(),
|
|
Summary = request.Summary,
|
|
Roles = request.Roles ?? [],
|
|
Inputs = request.Inputs,
|
|
Outputs = request.Outputs,
|
|
Actions = (request.Actions ?? [])
|
|
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk, Description = a.Description })
|
|
.ToList(),
|
|
Tools = request.Tools ?? [],
|
|
Context = request.Context ?? [],
|
|
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : request.Visibility,
|
|
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
|
|
GoldenTests = (request.GoldenTests ?? [])
|
|
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
|
.ToList(),
|
|
};
|
|
|
|
private static SkillManifest ToManifest(Skill skill) => new()
|
|
{
|
|
Id = skill.SkillKey,
|
|
Name = skill.Name,
|
|
Version = skill.Version,
|
|
Summary = skill.Summary,
|
|
Roles = [.. skill.Roles],
|
|
Inputs = skill.Inputs,
|
|
Outputs = skill.Outputs,
|
|
Actions = skill.Actions
|
|
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk.ToString(), Description = a.Description })
|
|
.ToList(),
|
|
Tools = [.. skill.Tools],
|
|
Context = [.. skill.Context],
|
|
Visibility = skill.Visibility.ToString(),
|
|
MinTier = skill.MinTier.ToString(),
|
|
GoldenTests = [.. skill.GoldenTests], // Skill.Index clones these onto the new row.
|
|
};
|
|
|
|
private static SkillSummary ToSummary(Skill skill) => new(
|
|
skill.SkillKey,
|
|
skill.Name,
|
|
skill.Version,
|
|
skill.Summary,
|
|
skill.Roles,
|
|
skill.Visibility.ToString(),
|
|
skill.MinTier.ToString(),
|
|
skill.Status.ToString(),
|
|
skill.Origin.ToString(),
|
|
skill.OrganizationId,
|
|
skill.GoldenTests.Count,
|
|
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString(), a.Description)).ToList());
|
|
|
|
private static SkillDetail ToDetail(Skill skill) => new(
|
|
ToSummary(skill),
|
|
skill.Inputs,
|
|
skill.Outputs,
|
|
skill.Tools,
|
|
skill.Context,
|
|
skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
|
|
skill.Body);
|
|
}
|