Skip to content
Merged
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
29 changes: 20 additions & 9 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Luminous Development Roadmap

> **Document Version:** 2.6.0
> **Document Version:** 2.7.0
> **Last Updated:** 2025-12-23
> **Status:** Active
> **TOGAF Phase:** Phase E/F (Opportunities, Solutions & Migration Planning)
Expand Down Expand Up @@ -103,7 +103,7 @@ Phase 6: Intelligence & Ecosystem
| Phase | Name | Focus | Key Deliverables | Status |
|-------|------|-------|------------------|--------|
| **0** | Foundation | Infrastructure | Azure IaC, .NET solution, Angular shell, Passwordless Auth, Local Dev, CI/CD, Docs | ✅ Complete |
| **1** | Core Platform | Multi-tenancy | Family sign-up, device linking, CosmosDB, web MVP | ⬜ Not Started |
| **1** | Core Platform | Multi-tenancy | Family sign-up, device linking, CosmosDB, web MVP | 🔄 In Progress (1.1 Complete) |
| **2** | Display & Calendar | Calendar visibility | Display app, calendar integration, SignalR sync | ⬜ Not Started |
| **3** | Native Mobile | Mobile apps | iOS (Swift), Android (Kotlin), push notifications | ⬜ Not Started |
| **4** | Task Management | Chores and routines | Task creation, completion tracking, rewards | ⬜ Not Started |
Expand Down Expand Up @@ -268,13 +268,23 @@ Deliver the multi-tenant platform with user registration, family creation, devic

### Scope

#### 1.1 Multi-Tenant API

- [ ] **1.1.1** Implement family (tenant) creation endpoint
- [ ] **1.1.2** Implement user registration and profile creation
- [ ] **1.1.3** Implement JWT claims with family context
- [ ] **1.1.4** Add family-scoped authorization policies
- [ ] **1.1.5** Implement tenant data isolation in repositories
#### 1.1 Multi-Tenant API ✅ COMPLETED

- [x] **1.1.1** Implement family (tenant) creation endpoint
- *Implemented: RegisterFamilyCommand creates family and owner atomically, returns auth token*
- *API: POST /api/auth/register*
- [x] **1.1.2** Implement user registration and profile creation
- *Implemented: UpdateUserProfileCommand, GetCurrentUserQuery, GetUserQuery*
- *API: GET /api/auth/me, PUT /api/users/family/{familyId}/{userId}/profile*
- [x] **1.1.3** Implement JWT claims with family context
- *Implemented: TokenService generates JWT with family_id, role, email_verified claims*
- *Claims: sub, family_id, role, display_name, email_verified*
- [x] **1.1.4** Add family-scoped authorization policies
- *Implemented: TenantValidationMiddleware validates family access on routes*
- *Policies: FamilyMember, FamilyAdmin, FamilyOwner*
- [x] **1.1.5** Implement tenant data isolation in repositories
- *Implemented: ITenantContext and TenantContext services*
- *All queries use familyId partition key; tenant access validated in handlers*

#### 1.2 Device Linking

Expand Down Expand Up @@ -695,3 +705,4 @@ These can be developed in parallel after Phase 0:
| 2.4.0 | 2025-12-22 | Luminous Team | Phase 0.4 Local Development Environment completed |
| 2.5.0 | 2025-12-22 | Luminous Team | Phase 0.5 CI/CD Pipeline completed |
| 2.6.0 | 2025-12-23 | Luminous Team | Phase 0.6 Documentation completed; Phase 0 complete |
| 2.7.0 | 2025-12-23 | Luminous Team | Phase 1.1 Multi-Tenant API completed |
59 changes: 59 additions & 0 deletions src/Luminous.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Luminous.Application.DTOs;
using Luminous.Application.Features.Auth.Commands;
using Luminous.Application.Features.Users.Queries;
using Luminous.Shared.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Luminous.Api.Controllers;

/// <summary>
/// Controller for authentication and registration.
/// </summary>
public class AuthController : ApiControllerBase
{
/// <summary>
/// Registers a new family and owner, returning authentication tokens.
/// This is the primary signup flow for creating a new family (tenant).
/// </summary>
/// <param name="command">The registration command.</param>
/// <returns>The created family and authentication tokens.</returns>
[HttpPost("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<FamilyCreationResultDto>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<ActionResult<ApiResponse<FamilyCreationResultDto>>> Register([FromBody] RegisterFamilyCommand command)
{
var result = await Mediator.Send(command);
return CreatedResponse($"/api/families/{result.Family.Id}", result);
}

/// <summary>
/// Gets the currently authenticated user's information.
/// </summary>
/// <returns>The current user's information.</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ApiResponse<UserDto>>> GetCurrentUser()
{
var result = await Mediator.Send(new GetCurrentUserQuery());
return OkResponse(result);
}

/// <summary>
/// Checks if an email is available for registration.
/// </summary>
/// <param name="email">The email to check.</param>
/// <returns>True if the email is available.</returns>
[HttpGet("check-email")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<EmailAvailabilityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<EmailAvailabilityDto>>> CheckEmailAvailability([FromQuery] string email)
{
var result = await Mediator.Send(new CheckEmailAvailabilityQuery { Email = email });
return OkResponse(result);
}
}
45 changes: 45 additions & 0 deletions src/Luminous.Api/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Luminous.Application.DTOs;
using Luminous.Application.Features.Users.Commands;
using Luminous.Application.Features.Users.Queries;
using Luminous.Shared.Contracts;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -19,9 +20,53 @@ public class UsersController : ApiControllerBase
/// <returns>The list of family members.</returns>
[HttpGet("family/{familyId}")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ApiResponse<IReadOnlyList<UserDto>>>> GetFamilyMembers(string familyId)
{
var result = await Mediator.Send(new GetFamilyMembersQuery { FamilyId = familyId });
return OkResponse(result);
}

/// <summary>
/// Gets a specific user by ID.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="userId">The user ID.</param>
/// <returns>The user details.</returns>
[HttpGet("family/{familyId}/{userId}")]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ApiResponse<UserDto>>> GetUser(string familyId, string userId)
{
var result = await Mediator.Send(new GetUserQuery { FamilyId = familyId, UserId = userId });
return OkResponse(result);
}

/// <summary>
/// Updates a user's profile.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="userId">The user ID.</param>
/// <param name="profile">The updated profile information.</param>
/// <returns>The updated user.</returns>
[HttpPut("family/{familyId}/{userId}/profile")]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ApiResponse<UserDto>>> UpdateProfile(
string familyId,
string userId,
[FromBody] UserProfileDto profile)
{
var command = new UpdateUserProfileCommand
{
FamilyId = familyId,
UserId = userId,
Profile = profile
};
var result = await Mediator.Send(command);
return OkResponse(result);
}
}
97 changes: 97 additions & 0 deletions src/Luminous.Api/Middleware/TenantValidationMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Luminous.Application.Common.Interfaces;
using Luminous.Shared.Contracts;

namespace Luminous.Api.Middleware;

/// <summary>
/// Middleware that validates tenant access for requests that include a family ID in the route.
/// Ensures users can only access data belonging to their own family (tenant).
/// </summary>
public partial class TenantValidationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantValidationMiddleware> _logger;

// Regex pattern to match family-scoped routes
[GeneratedRegex(@"/api/(?:families|users/family|devices|events/family|chores/family)/([a-zA-Z0-9_-]+)")]
private static partial Regex FamilyScopedRouteRegex();

public TenantValidationMiddleware(RequestDelegate next, ILogger<TenantValidationMiddleware> logger)
{
_next = next;
_logger = logger;
}

public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
{
var path = context.Request.Path.Value;

// Skip tenant validation for unauthenticated requests or non-family routes
if (!context.User.Identity?.IsAuthenticated ?? true)
{
await _next(context);
return;
}

// Check if the route contains a family ID
if (path != null)
{
var match = FamilyScopedRouteRegex().Match(path);
if (match.Success)
{
var requestedFamilyId = match.Groups[1].Value;

// Skip validation for POST /api/families (creating new family)
if (context.Request.Method == "POST" &&
path.Equals("/api/families", StringComparison.OrdinalIgnoreCase))
{
await _next(context);
return;
}

// Validate that the user has access to the requested family
if (!tenantContext.HasAccessToFamily(requestedFamilyId))
{
_logger.LogWarning(
"Tenant access denied: User with family {UserFamilyId} attempted to access family {RequestedFamilyId} at {Path}",
tenantContext.TenantId ?? "null",
requestedFamilyId,
path);

context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";

var response = ApiResponse<object>.Failure(
"TENANT_ACCESS_DENIED",
"You do not have access to this family's data.");

var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

await context.Response.WriteAsync(json);
return;
}
}
}

await _next(context);
}
}

/// <summary>
/// Extension methods for TenantValidationMiddleware.
/// </summary>
public static class TenantValidationMiddlewareExtensions
{
/// <summary>
/// Adds the tenant validation middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseTenantValidation(this IApplicationBuilder builder)
{
return builder.UseMiddleware<TenantValidationMiddleware>();
}
}
3 changes: 3 additions & 0 deletions src/Luminous.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
// Add API services
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<ITenantContext, TenantContext>();
builder.Services.AddScoped<ITokenService, TokenService>();

// Configure JWT settings
builder.Services.Configure<JwtSettings>(
Expand Down Expand Up @@ -180,6 +182,7 @@

// Add custom middleware
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseTenantValidation();

app.MapControllers();
app.MapHealthChecks("/health");
Expand Down
55 changes: 55 additions & 0 deletions src/Luminous.Api/Services/TenantContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Security.Claims;
using Luminous.Application.Common.Interfaces;

namespace Luminous.Api.Services;

/// <summary>
/// Provides tenant (family) context from the current HTTP context.
/// Implements multi-tenant data isolation by ensuring users can only access their family's data.
/// </summary>
public class TenantContext : ITenantContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<TenantContext> _logger;

public TenantContext(IHttpContextAccessor httpContextAccessor, ILogger<TenantContext> logger)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}

private ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;

/// <inheritdoc />
public string? TenantId => User?.FindFirstValue("family_id");

/// <inheritdoc />
public bool HasTenant => !string.IsNullOrEmpty(TenantId);

/// <inheritdoc />
public bool HasAccessToFamily(string familyId)
{
if (string.IsNullOrEmpty(familyId))
{
return false;
}

// Users can only access their own family
return string.Equals(TenantId, familyId, StringComparison.OrdinalIgnoreCase);
}

/// <inheritdoc />
public void EnsureAccessToFamily(string familyId)
{
if (!HasAccessToFamily(familyId))
{
_logger.LogWarning(
"User with family {UserFamilyId} attempted to access family {RequestedFamilyId}",
TenantId ?? "null",
familyId);

throw new UnauthorizedAccessException(
$"User does not have access to family '{familyId}'.");
}
}
}
Loading
Loading