Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/api/Data/AuthorizationRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Microsoft.UsEduCsu.Saas;

// TODO: Convert from camelCase to ProperCase during serialization
public class AuthorizationRequest
{
public string identity { get; set; }
public string role { get; set; }

public string Identity
{
get => identity;
}
public string Role
{
get => role;
}
}
1 change: 1 addition & 0 deletions src/api/Data/ContainerDetail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public class ContainerDetail
public string StorageExplorerDirectLink { get; set; }
public IDictionary<string, string> Metadata { get; internal set; }
public IList<StorageRbacEntry> Access { get; set; }
public bool CanModifyRbac { get; set; }
}
15 changes: 15 additions & 0 deletions src/api/Data/Subscription.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Microsoft.UsEduCsu.Saas.Data
{
internal class Subscription
{
public string Id { get; set; }
public string Name { get; set; }
public string TenantId { get; set; }
}
}
127 changes: 127 additions & 0 deletions src/api/Endpoints/FileSystems.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Text.Json;
using Azure.Identity;
using Azure.Storage.Files.DataLake;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -48,4 +50,129 @@ public static IActionResult GetContainer(
? new OkObjectResult(detail)
: new NotFoundObjectResult("Container or storage account doesn't exist");
}

[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[FunctionName("AuthorizationDelete")]
public static IActionResult AuthorizationDelete(
[HttpTrigger(AuthorizationLevel.Anonymous, "DELETE", Route = "FileSystems/{account}/{container}/authorization/{rbacId}")]
HttpRequest req, ILogger log, string account, string container, string rbacId)
{
// Verify Parameter
if (Services.Extensions.AnyNullOrEmpty(account, container, rbacId))
{
return new BadRequestResult();
}

if (req == null)
log.LogError("err");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't seem like a useful error message?


// Validate Authorized Principal
ClaimsPrincipalResult cpr = new(UserOperations.GetClaimsPrincipal(req));

if (!cpr.IsValid)
{
log.LogWarning("No valid ClaimsPrincipal found in the request: '{0}'", cpr.Message);
return new UnauthorizedResult();
}

var principalId = UserOperations.GetUserPrincipalId(cpr.ClaimsPrincipal);

// Get Role Operations Setup
var roleOps = new RoleOperations(log);

// Verify user can manage RBAC
ResourceGraphOperations rgo = new(log, new DefaultAzureCredential());
string accountResourceId = rgo.GetAccountResourceId(account);
var canModifyRbac = roleOps.CanModifyRbac(accountResourceId, container, principalId);

if (!canModifyRbac)
return new UnauthorizedResult();

// Submit Role Authorization Request
var roleAssignment = roleOps.DeleteRoleAssignment(accountResourceId, container, rbacId);
if (roleAssignment is null)
return new NotFoundResult();

// Http No-Content 204
return new NoContentResult();
}


[ProducesResponseType(typeof(StorageRbacEntry), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[FunctionName("AuthorizationCreate")]
public static IActionResult AuthorizationCreate(
[HttpTrigger(AuthorizationLevel.Anonymous, "POST", Route = "FileSystems/{account}/{container}/authorization")]
HttpRequest req, ILogger log, string account, string container)
{
// Validate Parameters
if (Services.Extensions.AnyNullOrEmpty(account, container))
{
return new BadRequestResult();
}

// Validate Authorized Principal
ClaimsPrincipalResult cpr = new(UserOperations.GetClaimsPrincipal(req));
if (!cpr.IsValid)
{
log.LogWarning("No valid ClaimsPrincipal found in the request: '{0}'", cpr.Message);
return new UnauthorizedResult();
}
var ownerPrincipalId = UserOperations.GetUserPrincipalId(cpr.ClaimsPrincipal);

// Get Role Operations Setup
var roleOps = new RoleOperations(log);

// Verify user can manage RBAC
ResourceGraphOperations rgo = new(log, new DefaultAzureCredential());
string accountResourceId = rgo.GetAccountResourceId(account);
var canModifyRbac = roleOps.CanModifyRbac(accountResourceId, container, ownerPrincipalId);

if (!canModifyRbac)
return new UnauthorizedResult();

// Request body is supposed to contain the user's identity claim
var rbac = (req.Body.Length > 0)
? JsonSerializer.Deserialize<AuthorizationRequest>(req.Body)
: new AuthorizationRequest();
if (Services.Extensions.AnyNullOrEmpty(rbac.Identity, rbac.Role))
return new BadRequestResult();

// Convert Identity into principal ID
var mgo = new MicrosoftGraphOperations(log, new DefaultAzureCredential());
var targetPrincipalId = mgo.GetObjectId(rbac.Identity);
if (Services.Extensions.AnyNullOrEmpty(targetPrincipalId))
return new BadRequestResult();

// Convert Shortened Rolls to Full Names
if (!rbac.Role.StartsWith("Storage Blob Data", StringComparison.OrdinalIgnoreCase))
rbac.role = $"Storage Blob Data {rbac.Role}";

// Submit Role Authorization Request
var roleAssignment = roleOps.AssignRole(accountResourceId, container, rbac.Role, targetPrincipalId);
if (roleAssignment == null)
return new ConflictObjectResult("The role assignment already exists.");

// Get Principal Name
var principalName = mgo.GetDisplayName(targetPrincipalId);

// Convert to Storage Entry
var storageRbacEntry = new StorageRbacEntry()
{
PrincipalId = roleAssignment.PrincipalId,
PrincipalName = principalName,
RoleName = roleAssignment.RoleName.Replace("Storage Blob Data ", string.Empty),
// Preserve only the GUID part of the role assignment ID
RoleAssignmentId = roleAssignment.RoleAssignmentId[(roleAssignment.RoleAssignmentId.LastIndexOf('/') + 1)..],
IsInherited = false,
Order = 0
};

return new OkObjectResult(storageRbacEntry);
}
}
23 changes: 14 additions & 9 deletions src/api/Endpoints/StorageAccounts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using static Microsoft.UsEduCsu.Saas.FileSystems;

namespace Microsoft.UsEduCsu.Saas;

Expand Down Expand Up @@ -97,7 +96,7 @@ private static List<ContainerDetail> PopulateContainerDetail(string account, str
Parallel.ForEach(filesystems, (fs) =>
{
var cd = GetContainerDetail(roleOperations, graphOps, account, accountResourceId,
fs.Name, fs.Properties);
fs.Name, fs.Properties, principalId);
containerDetails.Add(cd);
});
}
Expand All @@ -114,7 +113,7 @@ private static List<ContainerDetail> PopulateContainerDetail(string account, str
}

private static ContainerDetail GetContainerDetail(RoleOperations roleOps, MicrosoftGraphOperations graphOps,
string account, string accountResourceId, string container, FileSystemProperties properties)
string account, string accountResourceId, string container, FileSystemProperties properties, string principalId)
{
// TODO: Move to FileSystemOperations

Expand All @@ -136,9 +135,15 @@ private static ContainerDetail GetContainerDetail(RoleOperations roleOps, Micros

// Determine Access Roles
// TODO: Optimization opportunity: Retrieve the role assignments for the account once, and then only the assignments at the container scope
var roles = roleOps.GetStorageDataPlaneRoleAssignments(accountResourceId, container);
var rbacEntries = roles
.Where(r => validTypes.Contains(r.PrincipalType)) // Only display User and Groups (no Service Principals)
var roleAssignments = roleOps.GetStorageDataPlaneRoleAssignments(accountResourceId, container)
// TODO: Should this be true every time we get assignments?
.Where(r => validTypes.Contains(r.PrincipalType)); // Only display User and Groups (no Service Principals);

// Determine if the user can modify RBAC on this container
var CanModifyRbac = roleAssignments.Any(ra => roleOps.CanModifyRbac(ra, principalId));

// Cast RoleAssignment to StorageRbacEntry because that's what the client expects
var rbacEntries = roleAssignments
.Select(r => new StorageRbacEntry()
{
RoleName = r.RoleName.Replace("Storage Blob Data ", string.Empty),
Expand All @@ -148,8 +153,7 @@ private static ContainerDetail GetContainerDetail(RoleOperations roleOps, Micros
IsInherited = r.IsInherited,
RoleAssignmentId = r.RoleAssignmentId
})
.OrderBy(r => r.Order).ThenBy(r => r.PrincipalName
).ToList();
.OrderBy(r => r.Order).ThenBy(r => r.PrincipalName).ToList();

// Package in ContainerDetail
var uri = Configuration.GetStorageUri(account, container).ToString();
Expand All @@ -159,7 +163,8 @@ private static ContainerDetail GetContainerDetail(RoleOperations roleOps, Micros
Metadata = metadata,
Access = rbacEntries,
StorageExplorerDirectLink = $"storageexplorer://?v=2&tenantId={Configuration.TenantId}&type=fileSystem&container={container}&serviceEndpoint={HttpUtility.UrlEncode(uri)}",
Uri = uri
Uri = uri,
CanModifyRbac = CanModifyRbac
};

return cd;
Expand Down
7 changes: 6 additions & 1 deletion src/api/Services/Cache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.UsEduCsu.Saas.Data;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text.Json;

Expand Down Expand Up @@ -150,6 +151,9 @@ private T Items<T>(string itemName, string key, Func<T> updateMethod, DateTimeOf
// TODO: Handle RedisTimeoutException (retry)
byte[] byteArray = _cache.Get(nameKey);

#if DEBUG
Debug.WriteLine("Debug skipping cache: {0} {1}", nameKey, byteArray?.Length);
#else
// The cache will return value if found
// TODO: Consider ignoring cached value if 2 bytes only (empty JSON object)
if (byteArray != null)
Expand All @@ -159,6 +163,7 @@ private T Items<T>(string itemName, string key, Func<T> updateMethod, DateTimeOf
_logger.LogDebug($"{nameKey} (bytes: {byteArray.Length}) pulled from cache.");
return obj;
}
#endif

// Get User by invoking Function
T value = updateMethod.Invoke();
Expand Down Expand Up @@ -204,5 +209,5 @@ private void SetCacheValue<T>(string nameKey, T value, DateTimeOffset? expiratio
_logger.LogDebug($"{nameKey} (bytes: {data.Length}) written to cache.");
}

#endregion
#endregion
}
44 changes: 44 additions & 0 deletions src/api/Services/MicrosoftGraphOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using Azure.Core;
using Microsoft.Azure.Management.Authorization.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Graph;
using System;
Expand Down Expand Up @@ -41,6 +42,49 @@ internal string GetDisplayName(string userIdentifier)
return dirObj.AdditionalData["displayName"]?.ToString();
}

internal string GetObjectId(string principalName)
{
// If this looks like a UPN
if (principalName.Contains('@'))
{
Comment thread
SvenAelterman marked this conversation as resolved.
try
{
// Retrieve a user by userPrincipalName
var myTask = graphClient
.Users[principalName]
.Request()
.GetAsync(graphClientCancellationToken);

var directoryObject = myTask.Result;
return directoryObject?.Id;
}
catch (Exception ex)
{
log.LogInformation(ex, "User '{0}' not found.", principalName);
}
}
else
{
try
{
// Retrieve a group by name
var myTask = graphClient
.Groups[principalName]
.Request()
.GetAsync(graphClientCancellationToken);

var directoryObject = myTask.Result;
return directoryObject?.Id;
}
catch (Exception ex)
{
log.LogInformation(ex, "Group '{0}' not found.", principalName);
}
}

return null;
}

private DirectoryObject GetDirectoryObject(string principalId)
{
DirectoryObject GetDirectoryObjectMethod()
Expand Down
Loading