Skip to content

Commit

Permalink
Add a basic concept of tenant
Browse files Browse the repository at this point in the history
  • Loading branch information
kawazoe committed Aug 15, 2020
1 parent 731f346 commit 95dc270
Show file tree
Hide file tree
Showing 16 changed files with 236 additions and 47 deletions.
2 changes: 2 additions & 0 deletions Westmoor.DowntimePlanner/ClientApp/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { InPipe } from './pipes/in.pipe';
import { IncludesPipe } from './pipes/includes.pipe';
import { FilterPipe } from './pipes/filter.pipe';
import { TypeofPipe } from './pipes/typeof.pipe';
import { TenantHttpInterceptorService } from './services/http-interceptors/tenant-http-interceptor.service';

@NgModule({
declarations: [
Expand Down Expand Up @@ -126,6 +127,7 @@ import { TypeofPipe } from './pipes/typeof.pipe';
TypeaheadModule.forRoot()
],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: TenantHttpInterceptorService, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: AuthHttpInterceptorService, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: AnalyticsHttpInterceptorService, multi: true },
{ provide: ErrorHandler, useClass: ErrorHandlerService },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export class AnalyticsService {
this.appInsights.loadAppInsights();
}

public setUserContext(userId: string) {
this.appInsights.setAuthenticatedUserContext(userId);
public setUserContext(userId: string, accountId: string) {
this.appInsights.setAuthenticatedUserContext(userId, accountId);
}

public clearUserContext() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { capitalize } from '../../../lib/string';
import { OperatorProjection } from '../../../lib/rxjs/types';
import { environment } from '../../../environments/environment';
import { AnalyticsService } from './analytics.service';
import { TenantService } from './tenant.service';

export interface UserProfile {
sub: string;
Expand All @@ -20,6 +21,7 @@ export interface UserProfile {
updated_at: string;

'https://westmoor.rpg/ownership_id': string;
'https://westmoor.rpg/campaigns': string[];
'https://westmoor.rpg/permissions': string[];
}

Expand Down Expand Up @@ -98,6 +100,8 @@ export class AuthService {
.pipe(
first(),
map(c => {
this.tenant.current.next(null);
this.userSubject.next(null);
this.analytics.clearUserContext();
c.logout(this.logoutOptions);
})
Expand All @@ -106,9 +110,12 @@ export class AuthService {
public getUser$ = this.auth0Client$
.pipe(
switchMap(c => from(c.getUser())),
tap(u => {
this.analytics.setUserContext(u['https://westmoor.rpg/ownership_id'] || u.sub);
this.userSubject.next(u);
tap(user => {
const tenant = user['https://westmoor.rpg/campaigns'][0];
const identity = user['https://westmoor.rpg/ownership_id'] || user.sub;
this.analytics.setUserContext(identity, tenant);
this.tenant.current.next(tenant);
this.userSubject.next(user);
})
) as Observable<UserProfile>;

Expand All @@ -117,7 +124,8 @@ export class AuthService {
public user$ = this.userSubject.asObservable();

constructor(
private readonly analytics: AnalyticsService
private readonly analytics: AnalyticsService,
private readonly tenant: TenantService
) {
// Set up local auth streams
this.localAuthSetup();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';

import { TenantService } from './tenant.service';

describe('TenantService', () => {
let service: TenantService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TenantService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class TenantService {
readonly current = new ReplaySubject<string | null>(1);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TenantService } from '../business/tenant.service';
import { switchMap, take } from 'rxjs/operators';

function whitelist(url: string) {
return url.startsWith('/api');
}

@Injectable({
providedIn: 'root'
})
export class TenantHttpInterceptorService implements HttpInterceptor {
constructor(
private readonly tenant: TenantService
) {
}

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!whitelist(req.url)) {
return next.handle(req);
}

return this.tenant.current
.pipe(
take(1),
switchMap(tenant => next.handle(tenant
? req.clone({ headers: req.headers.set('X-Tenant-Id', tenant) })
: req
)
)
);
}
}
8 changes: 6 additions & 2 deletions Westmoor.DowntimePlanner/Entities/SharedWithEntity.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace Westmoor.DowntimePlanner.Entities
{
public class SharedWithEntity
{
[JsonConverter(typeof(StringEnumConverter))]
public SharedWithKind Kind { get; set; }
public string OwnershipId { get; set; }
public string Picture { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
}
8 changes: 8 additions & 0 deletions Westmoor.DowntimePlanner/Entities/SharedWithKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Westmoor.DowntimePlanner.Entities
{
public enum SharedWithKind
{
User,
Tenant
}
}
29 changes: 0 additions & 29 deletions Westmoor.DowntimePlanner/Extensions/AuthorizationExtensions.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Cosmos;
using Westmoor.DowntimePlanner.Entities;
using Westmoor.DowntimePlanner.Extensions;
using Westmoor.DowntimePlanner.Security;
using Westmoor.DowntimePlanner.Services;

namespace Westmoor.DowntimePlanner.Repositories
Expand All @@ -16,19 +15,22 @@ public class AspNetCoreCosmosEntityManipulator<TEntity> : ICosmosEntityManipulat
{
private readonly IClock _clock;
private readonly IUuidFactory _uuidFactory;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IIdentityAccessor _identityAccessor;
private readonly ITenantAccessor _tenantAccessor;
private readonly IUserService _userService;

public AspNetCoreCosmosEntityManipulator(
IClock clock,
IUuidFactory uuidFactory,
IHttpContextAccessor httpContextAccessor,
IIdentityAccessor identityAccessor,
ITenantAccessor tenantAccessor,
IUserService userService
)
{
_clock = clock;
_uuidFactory = uuidFactory;
_httpContextAccessor = httpContextAccessor;
_identityAccessor = identityAccessor;
_tenantAccessor = tenantAccessor;
_userService = userService;
}

Expand All @@ -37,12 +39,19 @@ IUserService userService

public Expression<Func<TEntity, bool>> GetScopeFilterPredicate()
{
return _httpContextAccessor.HttpContext.User.GetMyContentPredicate<TEntity>();
var ownershipId = _identityAccessor.Identity;
var campaignIds = _tenantAccessor.AccessibleTenants;

var identities = campaignIds
.Prepend(ownershipId)
.ToArray();

return e => e.SharedWith.Any(id => identities.Contains(id.OwnershipId));
}

public async Task<TEntity> CreateMetadataAsync(TEntity entity, string[] sharedWith)
{
var identity = _httpContextAccessor.HttpContext.User.Identity.Name;
var identity = _identityAccessor.Identity;
var ownershipIds = sharedWith.Prepend(identity);
var entitySharedWith = entity.SharedWith ?? new SharedWithEntity[0];

Expand Down Expand Up @@ -70,7 +79,7 @@ public async Task<TEntity> UpdateMetadataAsync(TEntity updatedEntity, TEntity en
updatedEntity.CreatedOn = entity.CreatedOn;
updatedEntity.CreatedBy = entity.CreatedBy;
updatedEntity.ModifiedOn = _clock.UtcNow;
updatedEntity.ModifiedBy = _httpContextAccessor.HttpContext.User.Identity.Name;
updatedEntity.ModifiedBy = _identityAccessor.Identity;

return updatedEntity;
}
Expand Down Expand Up @@ -102,9 +111,9 @@ private async Task<SharedWithEntity> CreateSharedWithEntityAsync(string ownershi
? null
: new SharedWithEntity
{
Kind = SharedWithKind.User,
OwnershipId = ownershipId,
Picture = user.Picture,
Username = user.Username,
Email = user.Email,
Name = user.Name
};
Expand Down
16 changes: 16 additions & 0 deletions Westmoor.DowntimePlanner/Security/HttpContextIdentityAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Http;

namespace Westmoor.DowntimePlanner.Security
{
public class HttpContextIdentityAccessor : IIdentityAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;

public HttpContextIdentityAccessor(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}

public string Identity => _httpContextAccessor.HttpContext.User.Identity.Name;
}
}
33 changes: 33 additions & 0 deletions Westmoor.DowntimePlanner/Security/HttpContextTenantAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

namespace Westmoor.DowntimePlanner.Security
{
public class HttpContextTenantAccessor : ITenantAccessor
{
public class Options
{
public string AccessibleTenantsClaimType { get; set; }
public string TenantClaimType { get; set; }
}

private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Options _options;

public HttpContextTenantAccessor(IHttpContextAccessor httpContextAccessor, IOptions<Options> options)
{
_httpContextAccessor = httpContextAccessor;
_options = options.Value;
}

public string[] AccessibleTenants => _httpContextAccessor.HttpContext.User.Claims
.Where(c => c.Type == _options.AccessibleTenantsClaimType)
.Select(c => c.Value)
.ToArray();

public string Tenant => _httpContextAccessor.HttpContext.User.Claims
.FirstOrDefault(c => c.Type == _options.TenantClaimType)
?.Value;
}
}
7 changes: 7 additions & 0 deletions Westmoor.DowntimePlanner/Security/IIdentityAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Westmoor.DowntimePlanner.Security
{
public interface IIdentityAccessor
{
string Identity { get; }
}
}
8 changes: 8 additions & 0 deletions Westmoor.DowntimePlanner/Security/ITenantAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Westmoor.DowntimePlanner.Security
{
public interface ITenantAccessor
{
string[] AccessibleTenants { get; }
string Tenant { get; }
}
}
43 changes: 43 additions & 0 deletions Westmoor.DowntimePlanner/Security/SecureHeadersIdentityFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace Westmoor.DowntimePlanner.Security
{
public static class SecureHeadersIdentityFactory
{
public static Func<TokenValidatedContext, Task> CreateValidator(
IEnumerable<(string headerKey, string sourceClaimType, string targetClaimType)> mappings
)
{
return ctx =>
{
var validatedClaims = mappings
.Select(mapping => (
mapping.sourceClaimType,
mapping.targetClaimType,
headerValue: ctx.Request.Headers
.FirstOrDefault(h =>
h.Key.Equals(mapping.headerKey, StringComparison.OrdinalIgnoreCase)
)
.Value
.First()
))
.Where(mapping => ctx.Principal.Claims
.Any(c =>
c.Type == mapping.sourceClaimType &&
c.Value == mapping.headerValue
)
)
.Select(mapping => new Claim(mapping.targetClaimType, mapping.headerValue));

ctx.Principal.AddIdentity(new ClaimsIdentity(validatedClaims, "SecureHeaders"));

return Task.CompletedTask;
};
}
}
}
Loading

0 comments on commit 95dc270

Please sign in to comment.