Skip to content

Commit 47f8375

Browse files
rmunnhahn-kev
andauthored
Allow admins (site or org) to download projects by code (#1829)
* Backend now knows if user can download any project Site admins and org admins can download arbitrary projects without needing to be a member (projects owned by their org, or any project at all in the case of site admins), so let's return a boolean flag for the frontend to enable UI for that. The UI will still need to handle "Hey, you're not actually authorized to download that project" because it will be used by org admins, not only by site admins who have all access. * Catch case where lexbox project not yet CRDT Instead of downloading a CRDT project with no commits, which leads to errors in FW Lite when it can't find a default writing system, we'll check first whether the Lexbox project is a CRDT project, and refuse to download it if it isn't. Currently this just displays an error notification, but soon we'll display this in the dialog box instead. * Much improved download-by-code dialog Errors now show in dialog, dialog doesn't close until download completed, dialog doesn't close if any errors. * Also display error if user may not download project Can happen if an org admin tries to download a project from another org * Fix lint error --------- Co-authored-by: Kevin Hahn <[email protected]>
1 parent 28a13ec commit 47f8375

File tree

23 files changed

+766
-234
lines changed

23 files changed

+766
-234
lines changed

backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.Json.Serialization;
12
using FwLiteShared.Auth;
23
using FwLiteShared.Sync;
34
using LcmCrdt;
@@ -9,6 +10,16 @@
910

1011
namespace FwLiteShared.Projects;
1112

13+
[JsonConverter(typeof(JsonStringEnumConverter))]
14+
public enum DownloadProjectByCodeResult
15+
{
16+
Success,
17+
Forbidden,
18+
NotCrdtProject,
19+
ProjectNotFound,
20+
ProjectAlreadyDownloaded,
21+
}
22+
1223
public record ProjectModel(
1324
string Name,
1425
string Code,
@@ -28,7 +39,7 @@ public record ProjectModel(
2839
};
2940
}
3041

31-
public record ServerProjects(LexboxServer Server, ProjectModel[] Projects);
42+
public record ServerProjects(LexboxServer Server, ProjectModel[] Projects, bool CanDownloadByCode);
3243
public class CombinedProjectsService(LexboxProjectService lexboxProjectService,
3344
CrdtProjectsService crdtProjectsService,
3445
IEnumerable<IProjectProvider> projectProviders,
@@ -44,21 +55,20 @@ public async Task<ServerProjects[]> RemoteProjects()
4455
ServerProjects[] serverProjects = new ServerProjects[lexboxServers.Length];
4556
for (var i = 0; i < lexboxServers.Length; i++)
4657
{
47-
var server = lexboxServers[i];
48-
var projectModels = await ServerProjects(server);
49-
serverProjects[i] = new ServerProjects(server, projectModels);
58+
serverProjects[i] = await ServerProjects(lexboxServers[i]);
5059
}
5160

5261
return serverProjects;
5362
}
5463

55-
private async Task<ProjectModel[]> ServerProjects(LexboxServer server, bool forceRefresh = false)
64+
private async Task<ServerProjects> ServerProjects(LexboxServer server, bool forceRefresh = false)
5665
{
5766
if (forceRefresh)
5867
lexboxProjectService.InvalidateProjectsCache(server);
5968
var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server);
60-
await UpdateProjectServerInfo(lexboxProjects, await lexboxProjectService.GetLexboxUser(server));
61-
var projectModels = lexboxProjects.Select(p => new ProjectModel(
69+
var user = await lexboxProjectService.GetLexboxUser(server);
70+
await UpdateProjectServerInfo(lexboxProjects.Projects, user);
71+
var projectModels = lexboxProjects.Projects.Select(p => new ProjectModel(
6272
p.Name,
6373
p.Code,
6474
Crdt: p.IsCrdtProject,
@@ -68,7 +78,7 @@ private async Task<ProjectModel[]> ServerProjects(LexboxServer server, bool forc
6878
server,
6979
p.Id))
7080
.ToArray();
71-
return projectModels;
81+
return new(server, projectModels, lexboxProjects.CanDownloadByCode);
7282
}
7383

7484
private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser)
@@ -83,10 +93,10 @@ private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProject
8393

8494

8595
[JSInvokable]
86-
public async Task<ProjectModel[]> ServerProjects(string serverId, bool forceRefresh)
96+
public async Task<ServerProjects?> ServerProjects(string serverId, bool forceRefresh)
8797
{
8898
var server = lexboxProjectService.Servers().FirstOrDefault(s => s.Id == serverId);
89-
if (server is null) return [];
99+
if (server is null) return null;
90100
return await ServerProjects(server, forceRefresh);
91101
}
92102

@@ -143,12 +153,36 @@ private ProjectRole FromRole(UserProjectRole role) =>
143153
_ => ProjectRole.Unknown
144154
};
145155

146-
public async Task DownloadProject(string code, LexboxServer server)
156+
[JSInvokable]
157+
public async Task<DownloadProjectByCodeResult> DownloadProjectByCode(string code, LexboxServer server, UserProjectRole? userRole = null)
147158
{
148159
var serverProjects = await ServerProjects(server, false);
149-
var project = serverProjects.FirstOrDefault(p => p.Code == code)
150-
?? throw new InvalidOperationException($"Project {code} not found on server {server.Authority}");
160+
var project = serverProjects.Projects.FirstOrDefault(p => p.Code == code);
161+
if (project is null)
162+
{
163+
if (serverProjects.CanDownloadByCode)
164+
{
165+
var (status, projectId) = await lexboxProjectService.GetLexboxProjectId(server, code);
166+
if (status != DownloadProjectByCodeResult.Success) return status;
167+
if (crdtProjectsService.ProjectExists(code)) return DownloadProjectByCodeResult.ProjectAlreadyDownloaded;
168+
var role = userRole.HasValue ? FromRole(userRole.Value) : ProjectRole.Editor;
169+
project = new ProjectModel(
170+
Name: code,
171+
Code: code,
172+
Crdt: true,
173+
Fwdata: false,
174+
Lexbox: true,
175+
Role: role,
176+
Server: server,
177+
Id: projectId
178+
);
179+
await DownloadProject(project);
180+
return DownloadProjectByCodeResult.Success;
181+
}
182+
return DownloadProjectByCodeResult.ProjectNotFound;
183+
}
151184
await DownloadProject(project);
185+
return DownloadProjectByCodeResult.Success;
152186
}
153187

154188
[JSInvokable]

backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,24 @@ public LexboxServer[] Servers()
6464
return Servers().FirstOrDefault(s => s.Id == projectData.ServerId);
6565
}
6666

67-
public async Task<FieldWorksLiteProject[]> GetLexboxProjects(LexboxServer server)
67+
public async Task<ListProjectsResult> GetLexboxProjects(LexboxServer server)
6868
{
6969
return await cache.GetOrCreateAsync(CacheKey(server),
7070
async entry =>
7171
{
7272
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
7373
var httpClient = await clientFactory.GetClient(server).CreateHttpClient();
74-
if (httpClient is null) return [];
74+
if (httpClient is null) return new([], false);
7575
try
7676
{
77-
return await httpClient.GetFromJsonAsync<FieldWorksLiteProject[]>("api/crdt/listProjects") ?? [];
77+
return await httpClient.GetFromJsonAsync<ListProjectsResult>("api/crdt/listProjectsV2") ?? new([], false);
7878
}
7979
catch (Exception e)
8080
{
8181
logger.LogError(e, "Error getting lexbox projects");
82-
return [];
82+
return new([], false);
8383
}
84-
}) ?? [];
84+
}) ?? new([], false);
8585
}
8686

8787
public async Task<LexboxUser?> GetLexboxUser(LexboxServer server)
@@ -94,18 +94,32 @@ private static string CacheKey(LexboxServer server)
9494
return $"Projects|{server.Authority.Authority}";
9595
}
9696

97-
public async Task<Guid?> GetLexboxProjectId(LexboxServer server, string code)
97+
public async Task<(DownloadProjectByCodeResult, Guid?)> GetLexboxProjectId(LexboxServer server, string code)
9898
{
9999
var httpClient = await clientFactory.GetClient(server).CreateHttpClient();
100-
if (httpClient is null) return null;
100+
if (httpClient is null) return (DownloadProjectByCodeResult.Forbidden, null);
101101
try
102102
{
103-
return await httpClient.GetFromJsonAsync<Guid?>($"api/crdt/lookupProjectId?code={code}");
103+
var result = await httpClient.GetAsync($"api/crdt/lookupProjectId?code={code}");
104+
if (result.StatusCode == System.Net.HttpStatusCode.Forbidden) // 403
105+
{
106+
return (DownloadProjectByCodeResult.Forbidden, null);
107+
}
108+
if (result.StatusCode == System.Net.HttpStatusCode.NotFound) // 404
109+
{
110+
return (DownloadProjectByCodeResult.ProjectNotFound, null);
111+
}
112+
if (result.StatusCode == System.Net.HttpStatusCode.NotAcceptable) // 406
113+
{
114+
return (DownloadProjectByCodeResult.NotCrdtProject, null);
115+
}
116+
var guid = await result.Content.ReadFromJsonAsync<Guid?>();
117+
return (DownloadProjectByCodeResult.Success, guid);
104118
}
105119
catch (Exception e)
106120
{
107121
logger.LogError(e, "Error getting lexbox project id");
108-
return null;
122+
return (DownloadProjectByCodeResult.Forbidden, null);
109123
}
110124
}
111125

backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
140140
builder.ExportAsEnum<UserProjectRole>().UseString();
141141
builder.ExportAsEnum<ProjectRole>().UseString();
142142
builder.ExportAsEnum<SyncStatus>().UseString();
143+
builder.ExportAsEnum<DownloadProjectByCodeResult>().UseString();
143144
builder.ExportAsEnum<SyncJobStatusEnum>().UseString();
144145
var serviceTypes = Enum.GetValues<DotnetService>()
145146
//lcm has it's own dedicated export, config is not a service just a object, and testing needs a custom export below

backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,22 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap
5353
async (IOptions<AuthConfig> options,
5454
CombinedProjectsService combinedProjectsService,
5555
string code,
56-
string serverAuthority
56+
string serverAuthority,
57+
[FromQuery] UserProjectRole? role
5758
) =>
5859
{
5960
var server = options.Value.GetServerByAuthority(serverAuthority);
60-
await combinedProjectsService.DownloadProject(code, server);
61-
return TypedResults.Ok();
61+
var result = await combinedProjectsService.DownloadProjectByCode(code, server, role);
62+
return result switch
63+
{
64+
DownloadProjectByCodeResult.Success => Results.Ok(),
65+
DownloadProjectByCodeResult.Forbidden => Results.Forbid(),
66+
DownloadProjectByCodeResult.NotCrdtProject => Results.InternalServerError("Not a CRDT project"),
67+
DownloadProjectByCodeResult.ProjectNotFound => Results.NotFound("Project not found"),
68+
DownloadProjectByCodeResult.ProjectAlreadyDownloaded => Results.NoContent(),
69+
// If we reach this point then we updated DownloadProjectByCodeResult and forgot to update this switch
70+
_ => Results.InternalServerError("DownloadProjectByCodeResult enum value not handled, please inform FW Lite devs")
71+
};
6272
});
6373
return group;
6474
}

backend/FwLite/LcmCrdt/CrdtProject.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using LcmCrdt.Project;
1+
using System.Text.Json.Serialization;
2+
using LcmCrdt.Project;
23
using Microsoft.Extensions.Caching.Memory;
34

45
namespace LcmCrdt;
@@ -40,6 +41,7 @@ public record ProjectData(string Name, string Code, Guid Id, string? OriginDomai
4041
public bool IsReadonly => Role is not UserProjectRole.Editor and not UserProjectRole.Manager;
4142
}
4243

44+
[JsonConverter(typeof(JsonStringEnumConverter))]
4345
public enum UserProjectRole
4446
{
4547
Unknown,

backend/LexBoxApi/Controllers/CrdtController.cs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,19 +93,43 @@ public async Task<ActionResult<FieldWorksLiteProject[]>> ListProjects()
9393
return myProjects;
9494
}
9595

96+
[HttpGet("listProjectsV2")]
97+
// Will eventually become `listProjects`, once current clients have been updated, at which point we'll
98+
// retire the V2 endpoint
99+
public async Task<ActionResult<ListProjectsResult>> ListProjectsWithDownloadRights()
100+
{
101+
var myProjects = await projectService.UserProjects(loggedInContext.User.Id)
102+
.Where(p => p.Type == ProjectType.FLEx)
103+
.Select(p => new FieldWorksLiteProject(p.Id,
104+
p.Code,
105+
p.Name,
106+
p.LastCommit != null,
107+
dbContext.Set<ServerCommit>().Any(c => c.ProjectId == p.Id),
108+
p.Users.Where(u => u.UserId == loggedInContext.User.Id).Select(m => m.Role).FirstOrDefault()))
109+
.ToArrayAsync();
110+
if (loggedInContext.User.IsOutOfSyncWithMyProjects(myProjects))
111+
{
112+
await lexAuthService.RefreshUser(LexAuthConstants.ProjectsClaimType);
113+
}
114+
return new ListProjectsResult(myProjects, loggedInContext.User.CanDownloadProjectsWithoutMembership());
115+
}
116+
96117
[HttpGet("lookupProjectId")]
97118
[ProducesResponseType(StatusCodes.Status200OK)]
119+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
98120
[ProducesResponseType(StatusCodes.Status404NotFound)]
121+
[ProducesResponseType(StatusCodes.Status406NotAcceptable)] // Closest HTTP code that fits the semantics for "not a CRDT project"
99122
[ProducesDefaultResponseType]
100123
public async Task<ActionResult<Guid>> GetProjectId(string code)
101124
{
102-
await permissionService.AssertCanViewProject(code);
125+
var allowed = await permissionService.CanViewProject(code);
126+
if (!allowed) return Forbid();
103127
var projectId = await projectService.LookupProjectId(code);
104-
if (projectId is null)
105-
{
106-
return NotFound();
107-
}
108-
128+
if (projectId is null) return NotFound();
129+
allowed = await permissionService.CanDownloadProject(projectId.Value);
130+
if (!allowed) return Forbid();
131+
var isCrdt = projectService.IsCrdtProject(projectId.Value);
132+
if (!isCrdt) return StatusCode(StatusCodes.Status406NotAcceptable);
109133
return Ok(projectId.Value);
110134
}
111135

backend/LexBoxApi/Services/ProjectService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,9 @@ public IQueryable<Project> UserProjects(Guid userId)
364364
{
365365
return dbContext.Projects.Where(p => p.Users.Select(u => u.UserId).Contains(userId));
366366
}
367+
368+
public bool IsCrdtProject(Guid projectId)
369+
{
370+
return dbContext.CrdtCommits(projectId).Any();
371+
}
367372
}

backend/LexCore/Auth/LexAuthUser.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,13 @@ public bool IsProjectMember(Guid projectId, params Span<ProjectRole> roles)
315315
return false;
316316
}
317317

318+
public bool CanDownloadProjectsWithoutMembership()
319+
{
320+
if (IsAdmin) return true;
321+
if (Orgs.Any(o => o.Role == OrgRole.Admin)) return true;
322+
return false;
323+
}
324+
318325
public bool HasFeature(FeatureFlag feature)
319326
{
320327
if (FeatureFlags is null) return false;

backend/LexCore/Entities/Project.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ public record FieldWorksLiteProject(
8282
bool IsCrdtProject,
8383
[property:JsonConverter(typeof(JsonStringEnumConverter))]ProjectRole Role);
8484

85+
public record ListProjectsResult(
86+
FieldWorksLiteProject[] Projects,
87+
bool CanDownloadByCode);
88+
8589
public enum ProjectMigrationStatus
8690
{
8791
//default value

backend/LexCore/ServiceInterfaces/IPermissionService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public interface IPermissionService
1010
ValueTask<bool> CanSyncProject(Guid projectId);
1111
ValueTask AssertCanSyncProject(string projectCode);
1212
ValueTask AssertCanSyncProject(Guid projectId);
13+
ValueTask<bool> CanDownloadProject(Guid projectId);
1314
ValueTask AssertCanDownloadProject(Guid projectId);
1415
ValueTask<bool> CanViewProject(Guid projectId, LexAuthUser? overrideUser = null);
1516
ValueTask AssertCanViewProject(Guid projectId, LexAuthUser? overrideUser = null);

0 commit comments

Comments
 (0)