Skip to content

Commit c40aca8

Browse files
authored
Merge pull request #5 from awork-io/codex/workspace-access-control
[Feature] Add workspace access control settings
2 parents f7dc298 + 412419f commit c40aca8

56 files changed

Lines changed: 1594 additions & 304 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/Auth/AuthCallbackResult.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Backend.Data.Entities;
2+
3+
namespace Backend.Auth;
4+
5+
public class AuthCallbackResult
6+
{
7+
public bool Success { get; set; }
8+
public string? Error { get; set; }
9+
public string? SessionToken { get; set; }
10+
public User? User { get; set; }
11+
}

backend/Auth/AuthInitResult.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Backend.Auth;
2+
3+
public class AuthInitResult
4+
{
5+
public required string AuthorizationUrl { get; set; }
6+
public required string State { get; set; }
7+
}

backend/Auth/AuthMiddleware.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ public static RouteHandlerBuilder RequireAuth(this RouteHandlerBuilder builder)
6262
{
6363
return builder.AddEndpointFilter(async (context, next) =>
6464
{
65-
var user = context.HttpContext.User;
65+
var httpContext = context.HttpContext;
66+
var user = httpContext.User;
6667
var userId = JwtService.GetUserId(user);
6768
var workspaceId = JwtService.GetWorkspaceId(user);
6869

@@ -71,6 +72,23 @@ public static RouteHandlerBuilder RequireAuth(this RouteHandlerBuilder builder)
7172
return Results.Unauthorized();
7273
}
7374

75+
var path = httpContext.Request.Path.Value ?? string.Empty;
76+
var isAuthStatusRoute =
77+
string.Equals(path, "/api/auth/me", StringComparison.OrdinalIgnoreCase) ||
78+
string.Equals(path, "/api/auth/logout", StringComparison.OrdinalIgnoreCase);
79+
80+
if (!isAuthStatusRoute)
81+
{
82+
var workspaceAccessService = httpContext.RequestServices.GetRequiredService<Backend.WorkspaceAccess.WorkspaceAccessService>();
83+
var hasFormsAccess = await workspaceAccessService.HasFormsAccess(userId.Value);
84+
if (!hasFormsAccess)
85+
{
86+
return Results.Json(
87+
new { error = "Forms access denied.", code = "FORMS_ACCESS_DENIED" },
88+
statusCode: StatusCodes.Status403Forbidden);
89+
}
90+
}
91+
7492
return await next(context);
7593
});
7694
}

backend/Auth/AuthService.cs

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ public class AuthService
1717
private readonly string _aworkAuthUrl;
1818
private readonly string _aworkTokenUrl;
1919
private readonly string _aworkUserInfoUrl;
20+
private readonly string _aworkPermissionsUrl;
2021
private readonly string _aworkDcrUrl;
2122

2223
private const string DefaultAworkApiBaseUrl = "https://api.awork.com/api/v1";
24+
private const string WorkspaceManageConfigFeature = "workspace-manage-config";
25+
private static readonly string[] WorkspaceManageConfigAccessLevels = ["manage", "write", "config"];
2326

2427
private const string DcrClientName = "awork Forms";
2528
private const string DcrScope = "offline_access full_access";
@@ -48,6 +51,7 @@ public AuthService(
4851
_aworkAuthUrl = $"{baseUrl}/accounts/authorize";
4952
_aworkTokenUrl = $"{baseUrl}/accounts/token";
5053
_aworkUserInfoUrl = $"{baseUrl}/me";
54+
_aworkPermissionsUrl = $"{baseUrl}/me/permissions";
5155
_aworkDcrUrl = $"{baseUrl}/clientapplications/register";
5256
}
5357

@@ -77,6 +81,9 @@ public static string GenerateState()
7781
return Base64UrlEncode(bytes);
7882
}
7983

84+
/// <summary>
85+
/// Starts the awork OAuth flow and persists the PKCE state.
86+
/// </summary>
8087
public async Task<AuthInitResult> InitiateAuth()
8188
{
8289
var clientId = await GetOrCreateDcrClientId();
@@ -103,6 +110,9 @@ public async Task<AuthInitResult> InitiateAuth()
103110
return new AuthInitResult { AuthorizationUrl = authUrl, State = state };
104111
}
105112

113+
/// <summary>
114+
/// Completes the OAuth callback, upserts the user, and issues the Forms session token.
115+
/// </summary>
106116
public async Task<AuthCallbackResult> HandleCallback(string code, string state)
107117
{
108118
OAuthState? pkceState;
@@ -133,24 +143,15 @@ public async Task<AuthCallbackResult> HandleCallback(string code, string state)
133143
if (userInfo == null)
134144
return new AuthCallbackResult { Success = false, Error = "Failed to get user info" };
135145

136-
var user = await UpsertUser(userInfo, tokenResult);
146+
var permissionSnapshot = await GetWorkspaceAccessPermissionSnapshot(tokenResult.AccessToken!);
147+
var user = await UpsertUser(userInfo, tokenResult, permissionSnapshot);
137148
var sessionToken = _jwtService.GenerateToken(user.Id, user.AworkUserId, user.AworkWorkspaceId);
138149

139150
return new AuthCallbackResult
140151
{
141152
Success = true,
142153
SessionToken = sessionToken,
143-
User = new UserDto
144-
{
145-
Id = user.Id,
146-
Email = user.Email,
147-
Name = user.Name,
148-
AvatarUrl = user.AvatarUrl,
149-
WorkspaceId = user.AworkWorkspaceId,
150-
WorkspaceName = user.WorkspaceName,
151-
WorkspaceUrl = user.WorkspaceUrl,
152-
HasRefreshToken = !string.IsNullOrEmpty(user.RefreshToken)
153-
}
154+
User = user
154155
};
155156
}
156157
catch (Exception ex)
@@ -160,6 +161,9 @@ public async Task<AuthCallbackResult> HandleCallback(string code, string state)
160161
}
161162
}
162163

164+
/// <summary>
165+
/// Clears the stored awork tokens for the given user.
166+
/// </summary>
163167
public async Task ClearUserTokens(Guid userId)
164168
{
165169
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -296,7 +300,33 @@ private async Task<TokenResult> ExchangeCodeForTokens(string code, string codeVe
296300
return userInfo;
297301
}
298302

299-
private async Task<User> UpsertUser(AworkUserInfo userInfo, TokenResult tokenResult)
303+
private async Task<WorkspaceAccessPermissionSnapshot> GetWorkspaceAccessPermissionSnapshot(string accessToken)
304+
{
305+
var request = new HttpRequestMessage(HttpMethod.Get, _aworkPermissionsUrl);
306+
request.Headers.Add("Authorization", $"Bearer {accessToken}");
307+
308+
var response = await _httpClient.SendAsync(request);
309+
if (!response.IsSuccessStatusCode) return new WorkspaceAccessPermissionSnapshot();
310+
311+
var json = await response.Content.ReadAsStringAsync();
312+
var permissions = JsonSerializer.Deserialize<AworkPermissionInfoResponse>(json, new JsonSerializerOptions
313+
{
314+
PropertyNameCaseInsensitive = true
315+
});
316+
317+
var canManageWorkspaceAccess = permissions?.UserPermission?.Permissions?.Any(permission =>
318+
string.Equals(permission.Feature, WorkspaceManageConfigFeature, StringComparison.OrdinalIgnoreCase) &&
319+
permission.AccessLevels?.Any(level =>
320+
WorkspaceManageConfigAccessLevels.Contains(level, StringComparer.OrdinalIgnoreCase)) == true) == true;
321+
322+
return new WorkspaceAccessPermissionSnapshot
323+
{
324+
IsAdmin = permissions?.UserPermission?.IsAdmin == true,
325+
CanManageWorkspaceAccess = canManageWorkspaceAccess
326+
};
327+
}
328+
329+
private async Task<User> UpsertUser(AworkUserInfo userInfo, TokenResult tokenResult, WorkspaceAccessPermissionSnapshot permissionSnapshot)
300330
{
301331
await using var db = await _dbFactory.CreateDbContextAsync();
302332

@@ -315,6 +345,8 @@ private async Task<User> UpsertUser(AworkUserInfo userInfo, TokenResult tokenRes
315345
{
316346
AworkUserId = userInfo.Id,
317347
AworkWorkspaceId = workspaceId,
348+
IsAworkAdmin = permissionSnapshot.IsAdmin,
349+
CanManageWorkspaceAccess = permissionSnapshot.CanManageWorkspaceAccess,
318350
WorkspaceName = userInfo.Workspace?.Name,
319351
WorkspaceUrl = userInfo.Workspace?.Url,
320352
Email = userInfo.Email ?? "",
@@ -335,6 +367,8 @@ private async Task<User> UpsertUser(AworkUserInfo userInfo, TokenResult tokenRes
335367
user.Email = userInfo.Email ?? user.Email;
336368
user.Name = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
337369
user.AvatarUrl = userInfo.ProfileImage;
370+
user.IsAworkAdmin = permissionSnapshot.IsAdmin;
371+
user.CanManageWorkspaceAccess = permissionSnapshot.CanManageWorkspaceAccess;
338372
if (!string.IsNullOrWhiteSpace(userInfo.Workspace?.Name))
339373
user.WorkspaceName = userInfo.Workspace.Name;
340374
if (!string.IsNullOrWhiteSpace(userInfo.Workspace?.Url))
@@ -349,12 +383,49 @@ private async Task<User> UpsertUser(AworkUserInfo userInfo, TokenResult tokenRes
349383
return user;
350384
}
351385

386+
/// <summary>
387+
/// Returns the stored local user by Forms user id.
388+
/// </summary>
352389
public async Task<User?> GetUserById(Guid userId)
353390
{
354391
await using var db = await _dbFactory.CreateDbContextAsync();
355392
return await db.Users.FindAsync(userId);
356393
}
357394

395+
/// <summary>
396+
/// Refreshes the stored admin and manage-config flags from awork.
397+
/// </summary>
398+
public async Task RefreshWorkspaceAccessPermission(Guid userId)
399+
{
400+
try
401+
{
402+
var accessToken = await GetValidAccessToken(userId);
403+
if (string.IsNullOrEmpty(accessToken))
404+
return;
405+
406+
var permissionSnapshot = await GetWorkspaceAccessPermissionSnapshot(accessToken);
407+
408+
await using var db = await _dbFactory.CreateDbContextAsync();
409+
var user = await db.Users.FindAsync(userId);
410+
if (user == null ||
411+
(user.CanManageWorkspaceAccess == permissionSnapshot.CanManageWorkspaceAccess &&
412+
user.IsAworkAdmin == permissionSnapshot.IsAdmin))
413+
return;
414+
415+
user.IsAworkAdmin = permissionSnapshot.IsAdmin;
416+
user.CanManageWorkspaceAccess = permissionSnapshot.CanManageWorkspaceAccess;
417+
user.UpdatedAt = DateTime.UtcNow;
418+
await db.SaveChangesAsync();
419+
}
420+
catch (Exception ex)
421+
{
422+
Console.WriteLine($"Failed to refresh workspace access permissions for user {userId}: {ex.Message}");
423+
}
424+
}
425+
426+
/// <summary>
427+
/// Returns a usable awork access token, refreshing it when needed.
428+
/// </summary>
358429
public async Task<string?> GetValidAccessToken(Guid userId, bool forceRefresh = false)
359430
{
360431
await using var db = await _dbFactory.CreateDbContextAsync();

backend/Auth/AuthTokenResponse.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Backend.Auth;
4+
5+
public class AuthTokenResponse
6+
{
7+
[JsonPropertyName("access_token")]
8+
public string AccessToken { get; set; } = string.Empty;
9+
10+
[JsonPropertyName("refresh_token")]
11+
public string? RefreshToken { get; set; }
12+
13+
[JsonPropertyName("expires_in")]
14+
public int ExpiresIn { get; set; }
15+
16+
[JsonPropertyName("token_type")]
17+
public string TokenType { get; set; } = string.Empty;
18+
}

backend/Auth/AworkPermission.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Backend.Auth;
2+
3+
public class AworkPermission
4+
{
5+
public string? Feature { get; set; }
6+
public List<string>? AccessLevels { get; set; }
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Backend.Auth;
2+
3+
public class AworkPermissionInfoResponse
4+
{
5+
public AworkUserPermission? UserPermission { get; set; }
6+
}

backend/Auth/AworkUserInfo.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Backend.Auth;
2+
3+
public class AworkUserInfo
4+
{
5+
public Guid Id { get; set; }
6+
public string? Email { get; set; }
7+
public string? FirstName { get; set; }
8+
public string? LastName { get; set; }
9+
public string? ProfileImage { get; set; }
10+
public Guid? WorkspaceId { get; set; }
11+
public Guid? AccountId { get; set; }
12+
public AworkWorkspaceInfo? Workspace { get; set; }
13+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Backend.Auth;
2+
3+
public class AworkUserPermission
4+
{
5+
public bool IsAdmin { get; set; }
6+
public List<AworkPermission>? Permissions { get; set; }
7+
}

backend/Auth/AworkWorkspaceInfo.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Backend.Auth;
2+
3+
public class AworkWorkspaceInfo
4+
{
5+
public Guid Id { get; set; }
6+
public string? Name { get; set; }
7+
public string? Url { get; set; }
8+
}

0 commit comments

Comments
 (0)