Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ADMINAPI-1119] - DRAFT - SPIKE: Cross-tenant Admin API Access #215

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using OpenIddict.Server.AspNetCore;
using Swashbuckle.AspNetCore.Annotations;

Expand Down Expand Up @@ -35,7 +36,9 @@ public ConnectController(ITokenService tokenService, IRegisterService registerSe
[SwaggerResponse(200, "Application registered successfully.")]
public async Task<IActionResult> Register([FromForm] RegisterService.RegisterClientRequest request)
{
if (await _registerService.Handle(request))
var tenantHeader = Request.Headers.FirstOrDefault(header => header.Key.Equals("tenant"));

if (await _registerService.Handle(request, tenantHeader.Value))
{
return Ok(new { Title = $"Registered client {request.ClientId} successfully.", Status = 200 });
}
Expand Down
20 changes: 14 additions & 6 deletions Application/EdFi.Ods.AdminApi/Features/Connect/RegisterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,29 @@
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using System.Text.RegularExpressions;
using EdFi.Ods.AdminApi.Infrastructure;
using EdFi.Ods.AdminApi.Infrastructure.Context;
using EdFi.Ods.AdminApi.Infrastructure.MultiTenancy;
using EdFi.Ods.AdminApi.Infrastructure.Security;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using Swashbuckle.AspNetCore.Annotations;
using System.Text.RegularExpressions;

namespace EdFi.Ods.AdminApi.Features.Connect;

public interface IRegisterService
{
Task<bool> Handle(RegisterService.RegisterClientRequest request);
Task<bool> Handle(RegisterService.RegisterClientRequest request, string? tenantIdentifier);
}

public class RegisterService : IRegisterService
{
private readonly IConfiguration _configuration;
private readonly Validator _validator;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictApplicationManager _applicationManager;

public RegisterService(IConfiguration configuration, Validator validator, IOpenIddictApplicationManager applicationManager)
{
Expand All @@ -31,7 +34,7 @@ public RegisterService(IConfiguration configuration, Validator validator, IOpenI
_applicationManager = applicationManager;
}

public async Task<bool> Handle(RegisterClientRequest request)
public async Task<bool> Handle(RegisterClientRequest request, string? tenantIdentifier)
{
if (!await RegistrationIsEnabledOrNecessary())
return false;
Expand All @@ -51,9 +54,14 @@ public async Task<bool> Handle(RegisterClientRequest request)
{
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,
OpenIddictConstants.Permissions.Prefixes.Scope + SecurityConstants.Scopes.AdminApiFullAccess
OpenIddictConstants.Permissions.Prefixes.Scope + SecurityConstants.Scopes.AdminApiFullAccess
},
};
};

if (!string.IsNullOrEmpty(tenantIdentifier))
Copy link
Author

Choose a reason for hiding this comment

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

On RegisterService, if a tenant as been sent on the request headers, add it to the application permissions.

{
application.Permissions.Add($"tnt:{tenantIdentifier}");
}

await _applicationManager.CreateAsync(application);
return true;
Expand Down
22 changes: 19 additions & 3 deletions Application/EdFi.Ods.AdminApi/Features/Connect/TokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@

using System.Security.Authentication;
using System.Security.Claims;
using EdFi.Ods.AdminApi.Infrastructure.Context;
using EdFi.Ods.AdminApi.Infrastructure.ErrorHandling;
using EdFi.Ods.AdminApi.Infrastructure.MultiTenancy;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.VisualBasic;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace EdFi.Ods.AdminApi.Features.Connect;

Expand All @@ -24,7 +28,7 @@ public class TokenService : ITokenService

public TokenService(IOpenIddictApplicationManager applicationManager)
{
_applicationManager = applicationManager;
_applicationManager = applicationManager;
}

public async Task<ClaimsPrincipal> Handle(OpenIddictRequest request)
Expand All @@ -46,7 +50,7 @@ public async Task<ClaimsPrincipal> Handle(OpenIddictRequest request)
var appScopes = (await _applicationManager.GetPermissionsAsync(application))
.Where(p => p.StartsWith(OpenIddictConstants.Permissions.Prefixes.Scope))
.Select(p => p[OpenIddictConstants.Permissions.Prefixes.Scope.Length..])
.ToList();
.ToList();

var missingScopes = requestedScopes.Where(s => !appScopes.Contains(s)).ToList();
if (missingScopes.Any())
Expand All @@ -56,7 +60,19 @@ public async Task<ClaimsPrincipal> Handle(OpenIddictRequest request)

var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);
identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId!, OpenIddictConstants.Destinations.AccessToken);
identity.AddClaim(OpenIddictConstants.Claims.Name, displayName!, OpenIddictConstants.Destinations.AccessToken);
identity.AddClaim(OpenIddictConstants.Claims.Name, displayName!, OpenIddictConstants.Destinations.AccessToken);
Copy link
Author

Choose a reason for hiding this comment

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

Include the tenant as a claim to the access token.


var permissions = await _applicationManager.GetPermissionsAsync(application);
string TenantIdentifier = permissions.FirstOrDefault(permission => permission.StartsWith("tnt")) ?? string.Empty;

if (!string.IsNullOrEmpty(TenantIdentifier))
{
identity.AddClaim("Tenant", TenantIdentifier.Split(':').AsEnumerable().ElementAt(1));
identity.SetDestinations(static claim => claim switch
{
_ => [Destinations.AccessToken]
});
}

var principal = new ClaimsPrincipal(identity);
principal.SetScopes(requestedScopes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
}
else
{
ThrowTenantValidationError($"Tenant not found with provided tenant id: {tenantIdentifier}");
ThrowTenantValidationError($"Tenant not found with provided tenant id: {tenantIdentifier}");
}
}
else
Expand Down Expand Up @@ -83,10 +83,10 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!NonFeatureEndpoints())
{
ThrowTenantValidationError("Tenant header is missing");
ThrowTenantValidationError("Tenant header is missing");
}
}
}
}
await next.Invoke(context);

bool RequestFromSwagger() => (context.Request.Path.Value != null &&
Expand All @@ -101,7 +101,7 @@ bool NonFeatureEndpoints() => context.Request.Path.Value != null &&

void ThrowTenantValidationError(string errorMessage)
{
throw new ValidationException(new[] { new ValidationFailure("Tenant", errorMessage) });
throw new ValidationException(new[] { new ValidationFailure("Tenant", errorMessage) });
}
}

Expand All @@ -116,5 +116,5 @@ private bool IsValidTenantId(string tenantId)
return false;
}
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using EdFi.Ods.AdminApi.Infrastructure.Extensions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server;
Expand Down Expand Up @@ -104,6 +105,18 @@ IWebHostEnvironment webHostEnvironment
{
opt.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireClaim(OpenIddictConstants.Claims.Scope, SecurityConstants.Scopes.AdminApiFullAccess)
.RequireAssertion(ctx =>
Copy link
Author

Choose a reason for hiding this comment

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

Check if the tenant value on the header matches the one in the token.

{
var claim = ctx.User.Claims.FirstOrDefault(c => c.Type == "Tenant");

if (claim is null)
return true; //Single Tenant

var httpContext = ctx.Resource as DefaultHttpContext;
var requestTenantHeader = httpContext?.Request.Headers.FirstOrDefault(h => h.Key.Equals("tenant")).Value;

return (!string.IsNullOrEmpty(requestTenantHeader) && requestTenantHeader.Value.Equals(claim.Value));
})
.Build();
});

Expand Down
Loading