feat: add ability to create folders and files and persist files to sharepoint

This commit is contained in:
Stevan Freeborn
2026-07-31 13:46:10 -05:00
parent 97d31630fc
commit ea69464606
72 changed files with 5068 additions and 300 deletions
+29 -17
View File
@@ -1,22 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# === Development stage ===
# Hot reload via dotnet watch with source volume mount
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS development
WORKDIR /src
COPY ParagonPlayground.slnx ./
COPY Directory.Build.props ./
COPY src/Directory.Build.props src/
COPY src/Directory.Packages.props src/
COPY src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj src/ParagonPlayground.Domain/
COPY src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj src/ParagonPlayground.Infrastructure/
COPY src/ParagonPlayground.Api/ParagonPlayground.Api.csproj src/ParagonPlayground.Api/
RUN dotnet restore src/ParagonPlayground.Api/ParagonPlayground.Api.csproj
COPY . .
RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
EXPOSE 8080
COPY --from=build /app .
RUN apt-get update && \
apt-get install -y unzip curl && \
@@ -24,4 +10,30 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Development
CMD ["dotnet", "watch", "run", "--project", "src/ParagonPlayground.Api/ParagonPlayground.Api.csproj", "--no-launch-profile"]
# === Build stage ===
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app
# === Debug stage ===
# Compiled binary with vsdbg for debugger attachment
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS debug
WORKDIR /app
EXPOSE 8080
RUN apt-get update && \
apt-get install -y unzip curl && \
curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Development
ENTRYPOINT ["dotnet", "ParagonPlayground.Api.dll"]
@@ -13,6 +13,8 @@
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,36 @@
namespace ParagonPlayground.Api.Auth;
internal static class PolicyNames
{
internal const string AdminOnly = "AdminOnly";
}
internal static class RoleNames
{
internal const string Admin = "admin";
}
internal static class SessionAuthDefaults
{
internal const string Scheme = "Session";
}
internal static class ClaimNames
{
internal const string OrganizationId = "OrganizationId";
internal const string OrganizationName = "OrganizationName";
internal const string OrganizationSlug = "OrganizationSlug";
}
internal static class EndpointAuthorizationExtensions
{
internal static RouteHandlerBuilder RequireAdmin(this RouteHandlerBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
_ = builder.RequireAuthorization(PolicyNames.AdminOnly);
return builder;
}
}
@@ -0,0 +1,80 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using ParagonPlayground.Api.Context;
using ParagonPlayground.Api.Infrastructure;
using ParagonPlayground.Infrastructure.Data;
using ParagonPlayground.Infrastructure.Services;
namespace ParagonPlayground.Api.Auth;
internal sealed class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
CookieService cookieService,
SessionRepository sessionRepository,
UserRepository userRepository,
OrganizationRepository organizationRepository
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
private readonly CookieService _cookieService = cookieService;
private readonly SessionRepository _sessionRepository = sessionRepository;
private readonly UserRepository _userRepository = userRepository;
private readonly OrganizationRepository _organizationRepository = organizationRepository;
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var sessionToken = _cookieService.GetSessionToken(Context);
if (string.IsNullOrEmpty(sessionToken))
{
return AuthenticateResult.NoResult();
}
var tokenHash = TokenHelper.HashToken(sessionToken);
var session = await _sessionRepository.GetByTokenHashAsync(tokenHash, Context.RequestAborted);
if (session is null || session.ExpiresAt <= DateTime.UtcNow)
{
return AuthenticateResult.Fail("Session token is invalid or expired.");
}
var user = await _userRepository.GetByIdAsync(session.UserId, Context.RequestAborted);
if (user is null)
{
return AuthenticateResult.Fail("The user associated with this session no longer exists.");
}
var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId, Context.RequestAborted);
if (organization is null)
{
return AuthenticateResult.Fail("The organization associated with this session no longer exists.");
}
Context.SetSession(session);
Context.SetSessionToken(sessionToken);
Context.SetUser(user);
Context.SetOrganization(organization);
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.DisplayName),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role),
new Claim(ClaimNames.OrganizationId, organization.Id),
new Claim(ClaimNames.OrganizationName, organization.Name),
new Claim(ClaimNames.OrganizationSlug, organization.Slug),
],
Scheme.Name
);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
}
}
@@ -6,4 +6,4 @@ internal static class ContextKeys
internal const string SessionToken = nameof(SessionToken);
internal const string User = nameof(User);
internal const string Organization = nameof(Organization);
}
}
@@ -29,9 +29,16 @@ internal static class HttpContextExtensions
context.Items[ContextKeys.User] = user;
}
internal static User? GetUser(this HttpContext context)
internal static User GetUser(this HttpContext context)
{
return context.Items[ContextKeys.User] as User;
return context.Items[ContextKeys.User] as User
?? throw new NotAuthenticatedException("The authenticated user is not available for this request.");
}
internal static bool TryGetUser(this HttpContext context, out User? user)
{
user = context.Items[ContextKeys.User] as User;
return user is not null;
}
internal static void SetOrganization(this HttpContext context, Organization? org)
@@ -39,8 +46,15 @@ internal static class HttpContextExtensions
context.Items[ContextKeys.Organization] = org;
}
internal static Organization? GetOrganization(this HttpContext context)
internal static Organization GetOrganization(this HttpContext context)
{
return context.Items[ContextKeys.Organization] as Organization;
return context.Items[ContextKeys.Organization] as Organization
?? throw new NotAuthenticatedException("The authenticated organization is not available for this request.");
}
}
internal static bool TryGetOrganization(this HttpContext context, out Organization? organization)
{
organization = context.Items[ContextKeys.Organization] as Organization;
return organization is not null;
}
}
@@ -0,0 +1,16 @@
namespace ParagonPlayground.Api.Context;
internal sealed class NotAuthenticatedException : Exception
{
public NotAuthenticatedException()
{
}
public NotAuthenticatedException(string message) : base(message)
{
}
public NotAuthenticatedException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -16,8 +16,8 @@ internal static class AuthEndpoints
internal static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
{
_ = group.MapPost("/login", LoginAsync);
_ = group.MapPost("/logout", LogoutAsync);
_ = group.MapGet("/me", Me);
_ = group.MapPost("/logout", LogoutAsync).RequireAuthorization();
_ = group.MapGet("/me", Me).RequireAuthorization();
return group;
}
@@ -38,7 +38,7 @@ internal static class AuthEndpoints
if (user is null || passwordService.Verify(request.Password, user.PasswordHash) is false)
{
return Results.Json(new { error = "Invalid email or password" }, statusCode: StatusCodes.Status401Unauthorized);
return Results.Problem(detail: "Invalid email or password", statusCode: StatusCodes.Status401Unauthorized);
}
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
@@ -65,6 +65,7 @@ internal static class AuthEndpoints
Id = user.Id,
Email = user.Email,
DisplayName = user.DisplayName,
Role = user.Role,
OrganizationId = org?.Id ?? string.Empty,
OrganizationName = org?.Name ?? string.Empty,
OrganizationSlug = org?.Slug ?? string.Empty,
@@ -88,18 +89,13 @@ internal static class AuthEndpoints
cookieService.ClearSessionCookie(context);
cookieService.ClearXsrfCookie(context);
return Results.Ok(new { message = "Logged out" });
return Results.NoContent();
}
private static IResult Me(HttpContext context)
{
var user = context.GetUser();
if (user is null)
{
return Results.Json(new { error = "Not authenticated" }, statusCode: StatusCodes.Status401Unauthorized);
}
var org = context.GetOrganization();
return Results.Ok(new UserResponse
@@ -107,6 +103,7 @@ internal static class AuthEndpoints
Id = user.Id,
Email = user.Email,
DisplayName = user.DisplayName,
Role = user.Role,
OrganizationId = org?.Id ?? string.Empty,
OrganizationName = org?.Name ?? string.Empty,
OrganizationSlug = org?.Slug ?? string.Empty,
@@ -0,0 +1,214 @@
using MongoDB.Bson;
using ParagonPlayground.Api.Auth;
using ParagonPlayground.Api.Context;
using ParagonPlayground.Api.Services;
using ParagonPlayground.Domain.DTOs;
using ParagonPlayground.Domain.Entities;
using ParagonPlayground.Infrastructure.Data;
namespace ParagonPlayground.Api.Endpoints;
internal static class IntegrationEndpoints
{
internal static RouteGroupBuilder MapIntegrationEndpoints(this RouteGroupBuilder group)
{
_ = group.MapGet("/config", GetConfig);
_ = group.MapPut("/config", PutConfig).RequireAdmin();
_ = group.MapGet("/credentials", GetCredentials);
_ = group.MapGet("/credentials/org", GetOrgCredentials).RequireAdmin();
_ = group.MapPost("/credentials", PostCredential);
_ = group.MapDelete("/credentials/org", PurgeOrgCredentials).RequireAdmin();
_ = group.MapDelete("/credentials/{credentialId}", DeleteCredential);
_ = group.RequireAuthorization();
return group;
}
private static async Task<IResult> GetConfig(
HttpContext context,
OrganizationIntegrationRepository repo,
CancellationToken ct
)
{
var org = context.GetOrganization();
var config = await repo.GetByOrganizationIdAsync(org.Id, ct);
if (config is null)
{
return Results.Problem(detail: "Integration configuration not found", statusCode: StatusCodes.Status404NotFound);
}
return Results.Ok(new IntegrationConfigResponse
{
Id = config.Id,
OrganizationId = config.OrganizationId,
ConnectionMode = config.ConnectionMode,
SharePointSiteUrl = config.SharePointSiteUrl,
SharePointSiteId = config.SharePointSiteId,
SharePointFolderPath = config.SharePointFolderPath,
UpdatedAt = config.UpdatedAt,
});
}
private static async Task<IResult> PutConfig(
IntegrationConfigRequest request,
HttpContext context,
OrganizationIntegrationRepository repo,
UserCredentialRepository credRepo,
ParagonService paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var config = await repo.GetByOrganizationIdAsync(org.Id, ct);
config ??= new OrganizationIntegration
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = org.Id,
};
config.ConnectionMode = request.ConnectionMode;
config.SharePointFolderPath = request.SharePointFolderPath?.Trim();
config.UpdatedAt = DateTime.UtcNow;
if (string.IsNullOrWhiteSpace(request.SharePointSiteUrl) is false)
{
config.SharePointSiteUrl = request.SharePointSiteUrl.Trim();
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c =>
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
if (spCredential is not null && paragon.IsConfigured)
{
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
config.SharePointSiteId = await paragon.ResolveSiteUrlAsync(
jwt,
spCredential.CredentialId,
config.SharePointSiteUrl,
ct
);
}
}
await repo.UpsertAsync(config, ct);
return Results.Ok(new IntegrationConfigResponse
{
Id = config.Id,
OrganizationId = config.OrganizationId,
ConnectionMode = config.ConnectionMode,
SharePointSiteUrl = config.SharePointSiteUrl,
SharePointSiteId = config.SharePointSiteId,
SharePointFolderPath = config.SharePointFolderPath,
UpdatedAt = config.UpdatedAt,
});
}
private static async Task<IResult> GetCredentials(
HttpContext context,
UserCredentialRepository repo,
CancellationToken ct
)
{
var user = context.GetUser();
var credentials = await repo.GetByUserIdAsync(user.Id, ct);
return Results.Ok(credentials.Select(c => new CredentialResponse
{
Id = c.Id,
CredentialId = c.CredentialId,
IntegrationType = c.IntegrationType,
ConnectedAt = c.ConnectedAt,
}));
}
private static async Task<IResult> GetOrgCredentials(
HttpContext context,
UserCredentialRepository repo,
CancellationToken ct
)
{
var org = context.GetOrganization();
var credentials = await repo.GetByOrganizationIdAsync(org.Id, ct);
return Results.Ok(credentials.Select(c => new CredentialResponse
{
Id = c.Id,
CredentialId = c.CredentialId,
IntegrationType = c.IntegrationType,
ConnectedAt = c.ConnectedAt,
}));
}
private static async Task<IResult> PostCredential(
CredentialRequest request,
HttpContext context,
UserCredentialRepository repo,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var credential = new UserCredential
{
Id = ObjectId.GenerateNewId().ToString(),
UserId = user.Id,
OrganizationId = org.Id,
CredentialId = request.CredentialId,
IntegrationType = request.IntegrationType,
ConnectedAt = DateTime.UtcNow,
};
await repo.CreateAsync(credential, ct);
return Results.Created($"/api/integration/credentials/{credential.Id}", new CredentialResponse
{
Id = credential.Id,
CredentialId = credential.CredentialId,
IntegrationType = credential.IntegrationType,
ConnectedAt = credential.ConnectedAt,
});
}
private static async Task<IResult> PurgeOrgCredentials(
HttpContext context,
UserCredentialRepository repo,
CancellationToken ct
)
{
var org = context.GetOrganization();
_ = await repo.DeleteByOrganizationIdAsync(org.Id, ct);
return Results.NoContent();
}
private static async Task<IResult> DeleteCredential(
string credentialId,
HttpContext context,
UserCredentialRepository repo,
CancellationToken ct
)
{
var user = context.GetUser();
var deleted = await repo.DeleteByCredentialIdAsync(credentialId, user.Id, ct);
if (deleted is false)
{
return Results.Problem(detail: "Credential not found", statusCode: StatusCodes.Status404NotFound);
}
return Results.NoContent();
}
}
@@ -0,0 +1,46 @@
using ParagonPlayground.Api.Context;
using ParagonPlayground.Api.Services;
using ParagonPlayground.Domain.DTOs;
using ParagonPlayground.Infrastructure.Data;
namespace ParagonPlayground.Api.Endpoints;
internal static class ParagonEndpoints
{
internal static RouteGroupBuilder MapParagonEndpoints(this RouteGroupBuilder group)
{
_ = group.MapGet("/token", GenerateToken);
_ = group.RequireAuthorization();
return group;
}
private static async Task<IResult> GenerateToken(
HttpContext context,
ParagonService paragon,
UserCredentialRepository credRepo,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
if (paragon.IsConfigured is false)
{
return Results.Problem(
detail: "Paragon integration is not configured. Set Paragon:ProjectId and Paragon:SigningKey.",
statusCode: StatusCodes.Status400BadRequest
);
}
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase));
var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId);
return Results.Ok(new ParagonTokenResponse
{
ParagonJwt = jwt,
ProjectId = paragon.ProjectId,
});
}
}
@@ -0,0 +1,329 @@
using System.Text.Json;
using MongoDB.Bson;
using ParagonPlayground.Api.Context;
using ParagonPlayground.Api.Services;
using ParagonPlayground.Domain.DTOs;
using ParagonPlayground.Domain.Entities;
using ParagonPlayground.Infrastructure.Data;
namespace ParagonPlayground.Api.Endpoints;
internal static class StorageEndpoints
{
internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group)
{
_ = group.MapGet("/", ListItems);
_ = group.MapPost("/folders", CreateFolder);
_ = group.MapPost("/files", UploadFile);
_ = group.MapDelete("/{id}", DeleteItem);
_ = group.MapGet("/{id}/download", GetDownloadUrls);
_ = group.MapGet("/{id}/content", ProxyContent);
_ = group.RequireAuthorization();
return group;
}
private static async Task<IResult> ListItems(
string? parentId,
HttpContext context,
StorageItemRepository repo,
UserRepository userRepo,
CancellationToken ct
)
{
var org = context.GetOrganization();
var items = await repo.GetByParentIdAsync(org.Id, parentId, ct);
var userIds = items.Select(i => i.CreatedByUserId).Distinct().ToList();
var users = new Dictionary<string, string>();
foreach (var uid in userIds)
{
var u = await userRepo.GetByIdAsync(uid, ct);
users[uid] = u?.DisplayName ?? "Unknown";
}
return Results.Ok(items.Select(i => new StorageItemResponse
{
Id = i.Id,
Name = i.Name,
IsFolder = i.IsFolder,
ParentId = i.ParentId,
ContentType = i.ContentType,
FileSize = i.FileSize,
SharePointWebUrl = i.SharePointWebUrl,
CreatedByUserId = i.CreatedByUserId,
CreatedByDisplayName = users.GetValueOrDefault(i.CreatedByUserId, "Unknown"),
CreatedAt = i.CreatedAt,
}));
}
private static async Task<IResult> CreateFolder(
CreateFolderRequest request,
HttpContext context,
StorageItemRepository repo,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.Problem(detail: "Folder name is required", statusCode: StatusCodes.Status400BadRequest);
}
var item = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = org.Id,
Name = request.Name.Trim(),
IsFolder = true,
ParentId = request.ParentId,
CreatedByUserId = user.Id,
CreatedAt = DateTime.UtcNow,
};
await repo.CreateAsync(item, ct);
return Results.Created($"/api/storage/{item.Id}", new StorageItemResponse
{
Id = item.Id,
Name = item.Name,
IsFolder = true,
ParentId = item.ParentId,
CreatedByUserId = item.CreatedByUserId,
CreatedByDisplayName = user.DisplayName,
CreatedAt = item.CreatedAt,
});
}
private static async Task<IResult> UploadFile(
HttpContext context,
StorageItemRepository storageRepo,
UserCredentialRepository credRepo,
OrganizationIntegrationRepository configRepo,
ParagonService paragon,
UserRepository userRepo,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var file = context.Request.Form.Files.Count > 0 ? context.Request.Form.Files[0] : null;
if (file is null || file.Length is 0)
{
return Results.Problem(detail: "File is required", statusCode: StatusCodes.Status400BadRequest);
}
var parentId = (string?)context.Request.Form["parentId"];
var config = await configRepo.GetByOrganizationIdAsync(org.Id, ct);
if (config is null || string.IsNullOrWhiteSpace(config.SharePointSiteId))
{
return Results.Problem(
detail: "SharePoint integration is not configured. Ask an admin to set it up.",
statusCode: StatusCodes.Status400BadRequest
);
}
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c =>
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
if (spCredential is null)
{
return Results.Problem(
detail: "No SharePoint credential found. Connect to SharePoint first.",
statusCode: StatusCodes.Status400BadRequest
);
}
if (paragon.IsConfigured is false)
{
return Results.Problem(
detail: "Paragon integration is not configured on the server.",
statusCode: StatusCodes.Status500InternalServerError
);
}
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
string sharePointResponse;
await using (var stream = file.OpenReadStream())
{
sharePointResponse = await paragon.UploadFileAsync(
jwt,
spCredential.CredentialId,
config.SharePointSiteId,
config.SharePointFolderPath ?? "",
file.FileName,
stream,
file.ContentType,
ct
);
}
string? driveItemId = null;
string? webUrl = null;
try
{
using var doc = JsonDocument.Parse(sharePointResponse);
if (doc.RootElement.TryGetProperty("output", out var output))
{
driveItemId = output.TryGetProperty("id", out var id) ? id.GetString() : null;
webUrl = output.TryGetProperty("webUrl", out var wu) ? wu.GetString() : null;
}
}
catch (JsonException)
{
// Response parsing is best-effort; store what we have
}
var storageItem = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = org.Id,
Name = file.FileName,
IsFolder = false,
ParentId = parentId,
ContentType = file.ContentType,
FileSize = file.Length,
SharePointSiteId = config.SharePointSiteId,
SharePointDriveItemId = driveItemId,
SharePointWebUrl = webUrl,
CreatedByUserId = user.Id,
CreatedAt = DateTime.UtcNow,
};
await storageRepo.CreateAsync(storageItem, ct);
return Results.Created($"/api/storage/{storageItem.Id}", new StorageItemResponse
{
Id = storageItem.Id,
Name = storageItem.Name,
IsFolder = false,
ParentId = storageItem.ParentId,
ContentType = storageItem.ContentType,
FileSize = storageItem.FileSize,
SharePointWebUrl = storageItem.SharePointWebUrl,
CreatedByUserId = storageItem.CreatedByUserId,
CreatedByDisplayName = user.DisplayName,
CreatedAt = storageItem.CreatedAt,
});
}
private static async Task<IResult> DeleteItem(
string id,
HttpContext context,
StorageItemRepository repo,
CancellationToken ct
)
{
var org = context.GetOrganization();
var item = await repo.GetByIdAsync(id, ct);
if (item is null || item.OrganizationId != org.Id)
{
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
}
await repo.DeleteAsync(id, ct);
return Results.NoContent();
}
private static async Task<IResult> GetDownloadUrls(
string id,
HttpContext context,
StorageItemRepository repo,
CancellationToken ct
)
{
var org = context.GetOrganization();
var item = await repo.GetByIdAsync(id, ct);
if (item is null || item.OrganizationId != org.Id || item.IsFolder)
{
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
}
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
return Results.Ok(new DownloadResponse
{
SharePointUrl = item.SharePointWebUrl,
ProxyUrl = $"{baseUrl}/api/storage/{item.Id}/content",
});
}
private static async Task<IResult> ProxyContent(
string id,
HttpContext context,
StorageItemRepository storageRepo,
UserCredentialRepository credRepo,
ParagonService paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var item = await storageRepo.GetByIdAsync(id, ct);
if (item is null || item.OrganizationId != org.Id || item.IsFolder)
{
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
}
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
{
return Results.Problem(
detail: "No SharePoint reference available for this file.",
statusCode: StatusCodes.Status400BadRequest
);
}
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c =>
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
if (spCredential is null)
{
return Results.Problem(
detail: "No SharePoint credential found.",
statusCode: StatusCodes.Status400BadRequest
);
}
if (paragon.IsConfigured is false)
{
return Results.Problem(
detail: "Paragon integration is not configured.",
statusCode: StatusCodes.Status500InternalServerError
);
}
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
var fileStream = await paragon.DownloadFileAsync(
jwt,
spCredential.CredentialId,
item.SharePointSiteId,
item.SharePointDriveItemId,
ct
);
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
}
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ParagonPlayground.Api.Context;
namespace ParagonPlayground.Api.Middleware;
internal sealed class NotAuthenticatedExceptionHandler(ILogger<NotAuthenticatedExceptionHandler> logger) : IExceptionHandler
{
private static readonly Action<ILogger, string, Exception> LogUnauthorizedAccess =
LoggerMessage.Define<string>(
LogLevel.Warning,
new EventId(1, "NotAuthenticatedException"),
"Session state was accessed without an authenticated request. Path: {Path}"
);
private readonly ILogger<NotAuthenticatedExceptionHandler> _logger = logger;
public ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken
)
{
if (exception is not NotAuthenticatedException)
{
return ValueTask.FromResult(false);
}
LogUnauthorizedAccess(_logger, httpContext.Request.Path.ToString(), exception);
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
if (httpContext.RequestServices.GetService<IProblemDetailsService>() is { } problemDetailsService)
{
return problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = exception.Message,
},
});
}
return ValueTask.FromResult(false);
}
}
@@ -1,52 +0,0 @@
using ParagonPlayground.Api.Context;
using ParagonPlayground.Api.Infrastructure;
using ParagonPlayground.Infrastructure.Data;
using ParagonPlayground.Infrastructure.Services;
namespace ParagonPlayground.Api.Middleware;
internal class SessionAuthMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next = next;
public async Task InvokeAsync(
HttpContext context,
SessionRepository sessionRepo,
UserRepository userRepo,
OrganizationRepository orgRepo,
CookieService cookieService
)
{
var ct = context.RequestAborted;
var sessionToken = cookieService.GetSessionToken(context);
if (string.IsNullOrEmpty(sessionToken) is false)
{
var tokenHash = TokenHelper.HashToken(sessionToken);
var session = await sessionRepo.GetByTokenHashAsync(tokenHash, ct);
if (session is not null && session.ExpiresAt > DateTime.UtcNow)
{
context.SetSession(session);
context.SetSessionToken(sessionToken);
var user = await userRepo.GetByIdAsync(session.UserId, ct);
if (user is not null)
{
context.SetUser(user);
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
if (org is not null)
{
context.SetOrganization(org);
}
}
}
}
await _next(context);
}
}
@@ -0,0 +1,12 @@
namespace ParagonPlayground.Api.Options;
internal class ParagonOptions
{
public const string SectionName = "Paragon";
public string ProjectId { get; set; } = string.Empty;
public string SigningKey { get; set; } = string.Empty;
public string ProxyBaseUrl { get; set; } = "https://proxy.useparagon.com";
}
@@ -2,6 +2,8 @@
<ItemGroup>
<PackageReference Include="MongoDB.Driver" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
</ItemGroup>
<ItemGroup>
@@ -1,17 +1,22 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using ParagonPlayground.Api.Auth;
using ParagonPlayground.Api.Endpoints;
using ParagonPlayground.Api.Middleware;
using ParagonPlayground.Api.Options;
using ParagonPlayground.Api.Services;
using ParagonPlayground.Infrastructure.Data;
using ParagonPlayground.Infrastructure.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MongoDbOptions>(builder.Configuration.GetSection(MongoDbOptions.SectionName));
builder.Services.AddSingleton(static sp =>
{
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<MongoDbOptions>>().Value;
var opts = sp.GetRequiredService<IOptions<MongoDbOptions>>().Value;
return new MongoDbContext(opts.ConnectionString, opts.DatabaseName);
});
@@ -20,6 +25,29 @@ builder.Services.AddSingleton<CookieService>();
builder.Services.AddSingleton<OrganizationRepository>();
builder.Services.AddSingleton<UserRepository>();
builder.Services.AddSingleton<SessionRepository>();
builder.Services.AddSingleton<StorageItemRepository>();
builder.Services.AddSingleton<UserCredentialRepository>();
builder.Services.AddSingleton<OrganizationIntegrationRepository>();
builder.Services.AddSingleton<ParagonService>();
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
builder.Services.AddHttpClient<ParagonApiClient>()
.AddStandardResilienceHandler();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<NotAuthenticatedExceptionHandler>();
builder.Services
.AddAuthentication(SessionAuthDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(
SessionAuthDefaults.Scheme,
_ => { }
);
builder.Services
.AddAuthorizationBuilder()
.AddPolicy(PolicyNames.AdminOnly, policy => policy.RequireRole(RoleNames.Admin));
builder.Services.Configure<ForwardedHeadersOptions>(static options =>
{
@@ -29,8 +57,14 @@ builder.Services.Configure<ForwardedHeadersOptions>(static options =>
var app = builder.Build();
app.UseForwardedHeaders();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseMiddleware<SessionAuthMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapGroup("/api/auth").MapAuthEndpoints();
app.MapGroup("/api/paragon").MapParagonEndpoints();
app.MapGroup("/api/integration").MapIntegrationEndpoints();
app.MapGroup("/api/storage").MapStorageEndpoints();
app.Run();
@@ -0,0 +1,107 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ParagonPlayground.Api.Options;
namespace ParagonPlayground.Api.Services;
internal sealed class ParagonApiClient
{
private readonly HttpClient _httpClient;
private readonly string _projectId;
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
{
_httpClient = httpClient;
_projectId = options.Value.ProjectId;
_httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/");
}
public async Task<string> UploadFileAsync(
string jwt,
string credentialId,
string siteId,
string folderPath,
string fileName,
Stream fileStream,
string contentType,
CancellationToken ct
)
{
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
using var ms = new MemoryStream();
await fileStream.CopyToAsync(ms, ct).ConfigureAwait(false);
ms.Position = 0;
using var request = new HttpRequestMessage(HttpMethod.Put, url)
{
Content = new StreamContent(ms),
};
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
public async Task<Stream> DownloadFileAsync(
string jwt,
string credentialId,
string siteId,
string driveItemId,
CancellationToken ct
)
{
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
+ $"/sites/{siteId}/drive/items/{driveItemId}/content";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
}
public async Task<string> ResolveSiteUrlAsync(
string jwt,
string credentialId,
string siteUrl,
CancellationToken ct
)
{
var uri = new Uri(siteUrl);
var segments = uri.AbsolutePath.TrimEnd('/')
.Split('/', StringSplitOptions.RemoveEmptyEntries);
var encodedPath = segments.Length > 0
? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString))
: "";
var proxyUrl = $"projects/{_projectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}";
using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var output = doc.RootElement.GetProperty("output");
var idProp = output.GetProperty("id");
return idProp.GetString() ?? "";
}
}
@@ -0,0 +1,106 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ParagonPlayground.Api.Options;
namespace ParagonPlayground.Api.Services;
internal sealed class ParagonService(IOptions<ParagonOptions> options, ParagonApiClient apiClient)
{
private readonly ParagonOptions _options = options.Value;
private readonly ParagonApiClient _apiClient = apiClient;
public string ProjectId => _options.ProjectId;
public bool IsConfigured =>
string.IsNullOrEmpty(_options.ProjectId) is false
&& string.IsNullOrEmpty(_options.SigningKey) is false;
public string GenerateToken(string organizationId, string? credentialId = null)
{
if (IsConfigured is false)
{
throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(_options.SigningKey);
var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" };
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var now = DateTime.UtcNow;
var permissions = credentialId is not null
? new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
[$"credential:{credentialId}"] = true,
},
}
: (object)new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
["credential:*"] = new[] { "credential:write" },
},
};
var claims = new[]
{
new Claim("sub", $"org:{organizationId}"),
new Claim("aud", $"useparagon.com/{_options.ProjectId}"),
new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)),
};
var token = new JwtSecurityToken(
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: signingCredentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public Task<string> UploadFileAsync(
string jwt,
string credentialId,
string siteId,
string folderPath,
string fileName,
Stream fileStream,
string contentType,
CancellationToken ct
)
{
return _apiClient.UploadFileAsync(jwt, credentialId, siteId, folderPath, fileName, fileStream, contentType, ct);
}
public Task<string> ResolveSiteUrlAsync(
string jwt,
string credentialId,
string siteUrl,
CancellationToken ct
)
{
return _apiClient.ResolveSiteUrlAsync(jwt, credentialId, siteUrl, ct);
}
public Task<Stream> DownloadFileAsync(
string jwt,
string credentialId,
string siteId,
string driveItemId,
CancellationToken ct
)
{
return _apiClient.DownloadFileAsync(jwt, credentialId, siteId, driveItemId, ct);
}
}
@@ -14,7 +14,8 @@ namespace ParagonPlayground.Cli.Commands;
internal class ProvisionUserCommand(
OrganizationRepository orgRepo,
UserRepository userRepo,
PasswordService passwordService) : AsyncCommand<ProvisionUserCommand.Settings>
PasswordService passwordService
) : AsyncCommand<ProvisionUserCommand.Settings>
{
internal class Settings : CommandSettings
{
@@ -33,6 +34,10 @@ internal class ProvisionUserCommand(
[Description("Organization slug")]
[CommandOption("-o|--org-slug")]
public required string OrgSlug { get; set; }
[Description("User role (admin or member)")]
[CommandOption("-r|--role")]
public string Role { get; set; } = "member";
}
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
@@ -60,6 +65,7 @@ internal class ProvisionUserCommand(
DisplayName = settings.Name,
PasswordHash = passwordService.Hash(settings.Password),
OrganizationId = org.Id,
Role = settings.Role,
CreatedAt = DateTime.UtcNow,
};
@@ -41,11 +41,11 @@ internal class SeedCommand(
var users = new[]
{
(Email: "alice@acme.com", Name: "Alice", Password: "password123"),
(Email: "bob@acme.com", Name: "Bob", Password: "password123"),
(Email: "alice@acme.com", Name: "Alice", Password: "password123", Role: "admin"),
(Email: "bob@acme.com", Name: "Bob", Password: "password123", Role: "member"),
};
foreach (var (email, name, password) in users)
foreach (var (email, name, password, role) in users)
{
var existing = await userRepo.GetByEmailAsync(email, cancellationToken);
@@ -58,6 +58,7 @@ internal class SeedCommand(
DisplayName = name,
PasswordHash = passwordService.Hash(password),
OrganizationId = org.Id,
Role = role,
CreatedAt = DateTime.UtcNow,
};
@@ -67,7 +68,16 @@ internal class SeedCommand(
}
else
{
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
if (existing.Role != role)
{
existing.Role = role;
await userRepo.ReplaceAsync(existing, cancellationToken);
AnsiConsole.MarkupLine($"[yellow]User '{email}' role updated to '{role}'[/]");
}
else
{
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
}
}
}
@@ -0,0 +1,2 @@
[*.{cs,vb}]
dotnet_diagnostic.CA1056.severity = none
@@ -0,0 +1,11 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Request to create a new virtual folder.</summary>
public class CreateFolderRequest
{
/// <summary>Folder name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Optional parent folder ID (null for root).</summary>
public string? ParentId { get; set; }
}
@@ -0,0 +1,11 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Request to store a new Paragon credential mapping.</summary>
public class CredentialRequest
{
/// <summary>Paragon credential ID from the integration install flow.</summary>
public string CredentialId { get; set; } = string.Empty;
/// <summary>Integration type (e.g. "sharepoint").</summary>
public string IntegrationType { get; set; } = string.Empty;
}
@@ -0,0 +1,17 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Stored Paragon credential for the current user.</summary>
public class CredentialResponse
{
/// <summary>Unique identifier.</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Paragon credential ID.</summary>
public string CredentialId { get; set; } = string.Empty;
/// <summary>Integration type (e.g. "sharepoint").</summary>
public string IntegrationType { get; set; } = string.Empty;
/// <summary>Timestamp when the credential was connected.</summary>
public DateTime ConnectedAt { get; set; }
}
@@ -0,0 +1,11 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Download URLs for a stored file.</summary>
public class DownloadResponse
{
/// <summary>Direct SharePoint web URL (opens in SharePoint).</summary>
public string? SharePointUrl { get; set; }
/// <summary>App-proxied download URL (streams through the backend via Paragon).</summary>
public string? ProxyUrl { get; set; }
}
@@ -0,0 +1,14 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Request to create or update the organization's integration configuration.</summary>
public class IntegrationConfigRequest
{
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
public string ConnectionMode { get; set; } = "default";
/// <summary>Target SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
public string? SharePointSiteUrl { get; set; }
/// <summary>Target folder path within the SharePoint site.</summary>
public string? SharePointFolderPath { get; set; }
}
@@ -0,0 +1,26 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Organization's Paragon/SharePoint integration configuration.</summary>
public class IntegrationConfigResponse
{
/// <summary>Unique identifier.</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Organization this config belongs to.</summary>
public string OrganizationId { get; set; } = string.Empty;
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
public string ConnectionMode { get; set; } = "default";
/// <summary>Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
public string? SharePointSiteUrl { get; set; }
/// <summary>Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid).</summary>
public string? SharePointSiteId { get; set; }
/// <summary>Target folder path within SharePoint.</summary>
public string? SharePointFolderPath { get; set; }
/// <summary>Timestamp of last update.</summary>
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,11 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Response containing a signed Paragon JWT and project ID.</summary>
public class ParagonTokenResponse
{
/// <summary>Signed JWT for authenticating with the Paragon SDK.</summary>
public string ParagonJwt { get; set; } = string.Empty;
/// <summary>Paragon project ID for SDK initialization.</summary>
public string ProjectId { get; set; } = string.Empty;
}
@@ -0,0 +1,35 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>File or folder returned by the storage API.</summary>
public class StorageItemResponse
{
/// <summary>Unique identifier.</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Display name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>True for folders, false for files.</summary>
public bool IsFolder { get; set; }
/// <summary>Parent folder ID (null for root items).</summary>
public string? ParentId { get; set; }
/// <summary>MIME type (null for folders).</summary>
public string? ContentType { get; set; }
/// <summary>File size in bytes (0 for folders).</summary>
public long FileSize { get; set; }
/// <summary>Direct SharePoint web URL (null for folders).</summary>
public string? SharePointWebUrl { get; set; }
/// <summary>User who created this item.</summary>
public string CreatedByUserId { get; set; } = string.Empty;
/// <summary>Display name of the creator.</summary>
public string CreatedByDisplayName { get; set; } = string.Empty;
/// <summary>Timestamp when the item was created.</summary>
public DateTime CreatedAt { get; set; }
}
@@ -20,4 +20,7 @@ public class UserResponse
/// <summary>URL-friendly slug of the user's organization.</summary>
public string OrganizationSlug { get; set; } = string.Empty;
/// <summary>User's role within the organization ("admin" or "member").</summary>
public string Role { get; set; } = "member";
}
@@ -0,0 +1,26 @@
namespace ParagonPlayground.Domain.Entities;
/// <summary>Per-organization Paragon/SharePoint integration configuration.</summary>
public class OrganizationIntegration
{
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Organization this config belongs to.</summary>
public string OrganizationId { get; set; } = string.Empty;
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
public string ConnectionMode { get; set; } = "default";
/// <summary>Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
public string? SharePointSiteUrl { get; set; }
/// <summary>Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid).</summary>
public string? SharePointSiteId { get; set; }
/// <summary>Target folder path within the SharePoint site.</summary>
public string? SharePointFolderPath { get; set; }
/// <summary>Timestamp of the last configuration update.</summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,41 @@
namespace ParagonPlayground.Domain.Entities;
/// <summary>Represents a file or folder in the virtual storage tree (independent of SharePoint structure).</summary>
public class StorageItem
{
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Organization this item belongs to.</summary>
public string OrganizationId { get; set; } = string.Empty;
/// <summary>Display name of the file or folder.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>True if this is a folder, false if it's a file.</summary>
public bool IsFolder { get; set; }
/// <summary>Parent folder ID (null for root-level items).</summary>
public string? ParentId { get; set; }
/// <summary>MIME type of the file (null for folders).</summary>
public string? ContentType { get; set; }
/// <summary>File size in bytes (0 for folders).</summary>
public long FileSize { get; set; }
/// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary>
public string? SharePointSiteId { get; set; }
/// <summary>SharePoint drive item ID (null for folders).</summary>
public string? SharePointDriveItemId { get; set; }
/// <summary>SharePoint web URL for direct access (null for folders).</summary>
public string? SharePointWebUrl { get; set; }
/// <summary>User who created this item.</summary>
public string CreatedByUserId { get; set; } = string.Empty;
/// <summary>Timestamp when the item was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -18,6 +18,9 @@ public class User
/// <summary>Identifier of the organization this user belongs to.</summary>
public string OrganizationId { get; set; } = string.Empty;
/// <summary>Role within the organization: "admin" or "member".</summary>
public string Role { get; set; } = "member";
/// <summary>Timestamp when the user was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,23 @@
namespace ParagonPlayground.Domain.Entities;
/// <summary>Maps an app user to their Paragon integration credential.</summary>
public class UserCredential
{
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
public string Id { get; set; } = string.Empty;
/// <summary>App user who owns this credential.</summary>
public string UserId { get; set; } = string.Empty;
/// <summary>Organization the user belongs to.</summary>
public string OrganizationId { get; set; } = string.Empty;
/// <summary>Paragon credential ID from the integration install flow.</summary>
public string CredentialId { get; set; } = string.Empty;
/// <summary>Integration type (e.g. "sharepoint").</summary>
public string IntegrationType { get; set; } = string.Empty;
/// <summary>Timestamp when the credential was connected.</summary>
public DateTime ConnectedAt { get; set; } = DateTime.UtcNow;
}
@@ -22,6 +22,18 @@ public sealed class MongoDbContext : IDisposable
public IMongoCollection<Session> Sessions =>
_database.GetCollection<Session>("Sessions");
/// <summary>Storage items collection.</summary>
public IMongoCollection<StorageItem> StorageItems =>
_database.GetCollection<StorageItem>("StorageItems");
/// <summary>User credentials collection.</summary>
public IMongoCollection<UserCredential> UserCredentials =>
_database.GetCollection<UserCredential>("UserCredentials");
/// <summary>Organization integrations collection.</summary>
public IMongoCollection<OrganizationIntegration> OrganizationIntegrations =>
_database.GetCollection<OrganizationIntegration>("OrganizationIntegrations");
/// <summary>Initializes a new MongoDbContext and connects to the specified database.</summary>
public MongoDbContext(string connectionString, string databaseName)
{
@@ -0,0 +1,38 @@
using MongoDB.Driver;
using ParagonPlayground.Domain.Entities;
namespace ParagonPlayground.Infrastructure.Data;
/// <summary>Repository for organization integration configuration data access.</summary>
public class OrganizationIntegrationRepository(MongoDbContext context)
{
private readonly MongoDbContext _context = context;
/// <summary>Finds the integration config for an organization.</summary>
public async Task<OrganizationIntegration?> GetByOrganizationIdAsync(
string organizationId,
CancellationToken ct
)
{
return await _context.OrganizationIntegrations
.Find(c => c.OrganizationId == organizationId)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Creates or replaces the integration config for an organization.</summary>
public async Task UpsertAsync(OrganizationIntegration config, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(config);
var filter = Builders<OrganizationIntegration>.Filter
.Eq(c => c.OrganizationId, config.OrganizationId);
var options = new ReplaceOptions() { IsUpsert = true };
_ = await _context.OrganizationIntegrations
.ReplaceOneAsync(filter, config, options, ct)
.ConfigureAwait(false);
}
}
@@ -12,18 +12,26 @@ public class OrganizationRepository(MongoDbContext context)
/// <summary>Finds an organization by its URL slug.</summary>
public async Task<Organization?> GetBySlugAsync(string slug, CancellationToken cancellationToken)
{
return await _context.Organizations.Find(o => o.Slug == slug).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return await _context.Organizations
.Find(o => o.Slug == slug)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Finds an organization by its unique identifier.</summary>
public async Task<Organization?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return await _context.Organizations.Find(o => o.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return await _context.Organizations
.Find(o => o.Id == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Creates a new organization in the database.</summary>
public async Task CreateAsync(Organization organization, CancellationToken cancellationToken)
{
await _context.Organizations.InsertOneAsync(organization, cancellationToken: cancellationToken).ConfigureAwait(false);
await _context.Organizations
.InsertOneAsync(organization, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
}
@@ -12,24 +12,33 @@ public class SessionRepository(MongoDbContext context)
/// <summary>Finds a session by its token hash.</summary>
public async Task<Session?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
{
return await _context.Sessions.Find(s => s.TokenHash == tokenHash).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return await _context.Sessions
.Find(s => s.TokenHash == tokenHash)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Creates a new session in the database.</summary>
public async Task CreateAsync(Session session, CancellationToken cancellationToken)
{
await _context.Sessions.InsertOneAsync(session, new InsertOneOptions(), cancellationToken).ConfigureAwait(false);
await _context.Sessions
.InsertOneAsync(session, new InsertOneOptions(), cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Deletes a session by its unique identifier.</summary>
public async Task DeleteAsync(string id, CancellationToken cancellationToken)
{
_ = await _context.Sessions.DeleteOneAsync(s => s.Id == id, cancellationToken).ConfigureAwait(false);
_ = await _context.Sessions
.DeleteOneAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Deletes all sessions for a given user.</summary>
public async Task DeleteByUserIdAsync(string userId, CancellationToken cancellationToken)
{
_ = await _context.Sessions.DeleteManyAsync(s => s.UserId == userId, cancellationToken).ConfigureAwait(false);
_ = await _context.Sessions
.DeleteManyAsync(s => s.UserId == userId, cancellationToken)
.ConfigureAwait(false);
}
}
@@ -0,0 +1,51 @@
using MongoDB.Driver;
using ParagonPlayground.Domain.Entities;
namespace ParagonPlayground.Infrastructure.Data;
/// <summary>Repository for storage item (file/folder) data access.</summary>
public class StorageItemRepository(MongoDbContext context)
{
private readonly MongoDbContext _context = context;
/// <summary>Lists items in a folder (or root items when parentId is null).</summary>
public async Task<List<StorageItem>> GetByParentIdAsync(
string organizationId, string? parentId, CancellationToken ct)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
return await _context.StorageItems.Find(filter)
.SortByDescending(i => i.IsFolder)
.ThenBy(i => i.Name)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a storage item by ID.</summary>
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.Id == id)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Creates a new storage item.</summary>
public async Task CreateAsync(StorageItem item, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(item);
await _context.StorageItems
.InsertOneAsync(item, cancellationToken: ct)
.ConfigureAwait(false);
}
/// <summary>Deletes a storage item by ID.</summary>
public async Task DeleteAsync(string id, CancellationToken ct)
{
_ = await _context.StorageItems
.DeleteOneAsync(i => i.Id == id, ct)
.ConfigureAwait(false);
}
}
@@ -0,0 +1,64 @@
using MongoDB.Driver;
using ParagonPlayground.Domain.Entities;
namespace ParagonPlayground.Infrastructure.Data;
/// <summary>Repository for user credential data access.</summary>
public class UserCredentialRepository(MongoDbContext context)
{
private readonly MongoDbContext _context = context;
/// <summary>Finds all credentials for a given user.</summary>
public async Task<List<UserCredential>> GetByUserIdAsync(string userId, CancellationToken ct)
{
return await _context.UserCredentials
.Find(c => c.UserId == userId)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds all credentials for a given organization.</summary>
public async Task<List<UserCredential>> GetByOrganizationIdAsync(string organizationId, CancellationToken ct)
{
return await _context.UserCredentials
.Find(c => c.OrganizationId == organizationId)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Stores a new credential mapping.</summary>
public async Task CreateAsync(UserCredential credential, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(credential);
await _context.UserCredentials
.InsertOneAsync(credential, cancellationToken: ct)
.ConfigureAwait(false);
}
/// <summary>Deletes all credentials for a given organization.</summary>
public async Task<long> DeleteByOrganizationIdAsync(string organizationId, CancellationToken ct)
{
var result = await _context.UserCredentials
.DeleteManyAsync(c => c.OrganizationId == organizationId, ct)
.ConfigureAwait(false);
return result.DeletedCount;
}
/// <summary>Deletes a credential by its Paragon credential ID for a given user.</summary>
public async Task<bool> DeleteByCredentialIdAsync(string credentialId, string userId, CancellationToken ct)
{
var filter = Builders<UserCredential>.Filter.And(
Builders<UserCredential>.Filter.Eq(c => c.CredentialId, credentialId),
Builders<UserCredential>.Filter.Eq(c => c.UserId, userId)
);
var result = await _context.UserCredentials
.DeleteOneAsync(filter, ct)
.ConfigureAwait(false);
return result.DeletedCount > 0;
}
}
@@ -18,12 +18,27 @@ public class UserRepository(MongoDbContext context)
/// <summary>Finds a user by their unique identifier.</summary>
public async Task<User?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return await _context.Users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return await _context.Users
.Find(u => u.Id == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Creates a new user in the database.</summary>
public async Task CreateAsync(User user, CancellationToken cancellationToken)
{
await _context.Users.InsertOneAsync(user, new InsertOneOptions(), cancellationToken).ConfigureAwait(false);
await _context.Users
.InsertOneAsync(user, new InsertOneOptions(), cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Replaces an existing user document.</summary>
public async Task ReplaceAsync(User user, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
_ = await _context.Users
.ReplaceOneAsync(u => u.Id == user.Id, user, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
}