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
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ An ERP for service companies: users and permissions, service catalog, clients an
## Highlights

- **Per-screen permissions.** Each permission profile gives one of four ordered levels (*Denied < Reader < Editor < Manager*) to every screen. A user may hold several profiles, and the effective level on each screen is the highest one among them.
- **Invitation-based accounts.** Nobody signs up alone: a new user receives a single-use activation link by e-mail and creates their own password. Nobody ever sees or types someone else's password.
- **Accounts created by the team.** Nobody signs up alone: whoever registers a user sets a temporary password, and the system makes the person replace it when they first sign in.
- **Safe concurrent editing.** Optimistic concurrency rejects a save when someone else changed the record in the meantime, and tells who did it.
- **Traceability.** Every record stores who created it, who last changed it and when. Users are deactivated, never deleted.
- **Test-driven development.** Every behavior starts as a failing test, in every layer, and business rules carry stable IDs that the tests cite ([decision 27](docs/decisoes-de-arquitetura.md)).
Expand All @@ -28,7 +28,6 @@ An ERP for service companies: users and permissions, service catalog, clients an
| Architecture | Clean Architecture, tactical DDD, CQRS without MediatR, Result pattern with Problem Details |
| Data | PostgreSQL, EF Core |
| Security | ASP.NET Core Identity, JWT with rotating refresh tokens, rate limiting |
| E-mail | SMTP with MailKit; Mailpit as the local test inbox |
| Local environment | .NET Aspire (PostgreSQL and Mailpit in Docker, dashboard with logs and traces) |
| Tests | xUnit v3, Shouldly, NSubstitute, NetArchTest, WebApplicationFactory, Testcontainers |
| Quality | Warnings as errors, analyzers, central package management, GitHub Actions, Dependabot |
Expand All @@ -39,7 +38,7 @@ An ERP for service companies: users and permissions, service catalog, clients an
backend/ControlService/src
├── ControlService.Domain Entities, value objects and business rules (no dependencies)
├── ControlService.Application Use cases and the interfaces they need
├── ControlService.Infrastructure EF Core, Identity, e-mail: implementations of those interfaces
├── ControlService.Infrastructure EF Core, Identity: implementations of those interfaces
├── ControlService.API Minimal API endpoints and composition root
├── ControlService.AppHost Aspire orchestration for local development
└── ControlService.ServiceDefaults Telemetry, health checks and resilience
Expand Down Expand Up @@ -92,7 +91,7 @@ The current slice is tracked in the milestone [M1: Sign-in, users and permission
- [x] Persistence: EF Core, audit fields, concurrency, seeded system records
- [x] Sign-in, sessions and the current user (`/me`)
- [ ] Per-screen authorization
- [ ] Permission profiles and users endpoints, activation and password reset links
- [ ] Permission profiles and users endpoints, with temporary passwords
- [ ] Connect the front-end to the real API
- [ ] Remaining screens: catalog, commercial, finance and reports

Expand Down
6 changes: 3 additions & 3 deletions backend/ControlService/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ Domain ◄── Application ◄── Infrastructure
| Projeto | O que vai aqui | Pode referenciar |
|---|---|---|
| `ControlService.Domain` | Entidades, objetos de valor e regras de negócio. Nada de EF Core ou ASP.NET. | Nada |
| `ControlService.Application` | Casos de uso (comandos e consultas), validação e as **interfaces** de que eles precisam (repositórios, e-mail, usuário atual). | Domain |
| `ControlService.Infrastructure` | As **implementações** dessas interfaces: EF Core, Identity, envio de e-mail. | Application, Domain |
| `ControlService.Application` | Casos de uso (comandos e consultas), validação e as **interfaces** de que eles precisam (repositórios, credenciais, usuário atual). | Domain |
| `ControlService.Infrastructure` | As **implementações** dessas interfaces: EF Core, Identity. | Application, Domain |
| `ControlService.API` | Endpoints HTTP, autenticação, autorização e o `Program.cs`, que liga tudo. | Application, Infrastructure |
| `ControlService.AppHost` / `ServiceDefaults` | Ambiente local com Aspire e telemetria. | API |

Expand Down Expand Up @@ -116,6 +116,6 @@ Pastas previstas para a primeira etapa: `Access` (níveis, chaves das telas, ace
3. Autenticação (Identity, JWT, token de renovação).
4. Autorização por tela.
5. Perfis de permissão.
6. Usuários e links de ativação e troca de senha.
6. Usuários, com senha temporária e troca obrigatória.

As rotas de cada funcionalidade estão na seção *Operations* do documento dela, em [docs/product/features](../../docs/product/features/), e o que vale para todas as rotas está em [docs/api/conventions.md](../../docs/api/conventions.md).
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

namespace ControlService.API.Auth;

/// <summary>While the Admin has not replaced the initial password, its access token carries
/// <summary>While the account has not replaced its temporary password, its access token carries
/// `must_change_password` and every endpoint except the ones that opted out answers 401
/// `password_change_required` (AUTH-14, T8).</summary>
/// `password_change_required` (AUTH-14).</summary>
public sealed class PasswordChangeRequiredFilter : IEndpointFilter
{
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
Expand All @@ -22,7 +22,7 @@ public sealed class PasswordChangeRequiredFilter : IEndpointFilter
}
}

/// <summary>Marks an endpoint the person may call before replacing the initial password: `me`, sign-out
/// <summary>Marks an endpoint the person may call before replacing the temporary password: `me`, sign-out
/// and change-password.</summary>
public sealed record AllowPendingPasswordChangeMetadata;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ControlService.API.Auth;

/// <summary>Requests per client address in a 60-second window (AUTH-22). Sign-in and refresh
/// each have their own limit; the global limit and the e-mail routes come with their own issues.</summary>
/// each have their own limit; no other route is limited.</summary>
public sealed class RateLimitingOptions
{
public const string SectionName = "RateLimiting";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ControlService.API.Common;
public static class ApiV1Group
{
/// <summary>Every endpoint requires a signed-in person unless it opts out with `AllowAnonymous()`, and
/// refuses a person who still has to replace the initial password unless it opts out with
/// refuses a person who still has to replace the temporary password unless it opts out with
/// `AllowPendingPasswordChange()` (AUTH-14).</summary>
public static RouteGroupBuilder MapApiV1(this IEndpointRouteBuilder app) =>
app.MapGroup("/api/v1").RequireAuthorization().AddEndpointFilter<PasswordChangeRequiredFilter>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Content-Type: application/json

###

# First access of the Admin: the initial password must be replaced (AUTH-14).
# Previous plan, still in the code (decision 21): the Admin must replace its initial password.
POST {{ControlService.API_HostAddress}}/api/v1/auth/change-password
Authorization: Bearer {{accessToken}}
Content-Type: application/json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task<Result<SessionGrant>> Handle(ChangePasswordCommand command, Ca
return Result<SessionGrant>.Failure(AuthErrors.NewPasswordEqualsInitial);
}

// The account with a mandatory change is the Admin, and this is its own first access (USR-34).
// Previous plan, still in the code (decision 21): the account with a mandatory change is the Admin.
// The user is saved before the password changes: a retry after a failure repeats harmlessly.
user.CompleteFirstAccess(timeProvider.GetUtcNow());
var saved = await unitOfWork.SaveChangesAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ public interface ICredentialStore
/// <summary>Sets a new password and clears the mandatory-change flag.</summary>
Task ReplacePasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken);

/// <summary>True while the account still has its initial password (AUTH-14).</summary>
/// <summary>True while the account still has a temporary password (AUTH-14).</summary>
Task<bool> MustChangePasswordAsync(Guid userId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ public Result Activate(DateTimeOffset now)
return Result.Success();
}

/// <summary>The Admin replacing its initial password is its own first access (USR-34): the system-record
/// guard does not apply, for the same reason as AUTH-15.</summary>
/// <summary>Previous plan, still in the code (decision 21): the Admin replacing its initial password
/// fills its activation time. It is not an edit of the record, so the system-record guard does not apply.</summary>
public void CompleteFirstAccess(DateTimeOffset now) => ActivatedAt ??= now;

public Result Deactivate(Guid by, DateTimeOffset now)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ namespace ControlService.Infrastructure.Auth;
/// needs syncing when a login changes; Identity's e-mail and phone columns stay empty (D12).</summary>
public sealed class UserCredential : IdentityUser<Guid>
{
/// <summary>True while the account still has its initial password (AUTH-14).</summary>
/// <summary>True while the account still has a temporary password (AUTH-14).</summary>
public bool MustChangePassword { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
namespace ControlService.Infrastructure.Persistence;

/// <summary>The Admin's e-mail and initial password come from configuration, each read only when
/// the record it creates is missing (USR-24, USR-33, AUTH-13).</summary>
/// the record it creates is missing (USR-24, USR-33). The password from configuration is the previous
/// plan: it becomes the fixed one of the demo account (AUTH-13, decision 21).</summary>
public sealed class AdminOptions
{
public const string SectionName = "Admin";

public required string Email { get; init; }

/// <summary>Never logged, echoed or committed (AUTH-13): it lives in user secrets.</summary>
/// <summary>Never logged, echoed or committed: it lives in user secrets.</summary>
public required string InitialPassword { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace ControlService.Api.IntegrationTests.Auth;
public sealed class AdminCredentialTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
[Fact]
public async Task Startup_creates_the_admin_credential_with_a_mandatory_change() // AUTH-13
public async Task Startup_creates_the_admin_credential_with_a_mandatory_change() // previous plan, see decision 21 (was AUTH-13)
{
// The first host already created it; removing it lets the next start prove it is created when missing.
await AuthTestSupport.DeleteAdminCredentialAsync(factory.Services);
Expand All @@ -29,7 +29,7 @@ public async Task Startup_creates_the_admin_credential_with_a_mandatory_change()
}

[Fact]
public async Task Restarting_does_not_change_the_admin_credential() // AUTH-13, T3
public async Task Restarting_does_not_change_the_admin_credential() // previous plan, see decision 21 (was AUTH-13), T3
{
await AuthTestSupport.DeleteAdminCredentialAsync(factory.Services);
using var first = factory.WithWebHostBuilder(_ => { });
Expand All @@ -54,7 +54,7 @@ public async Task Restarting_does_not_change_the_admin_credential() // AUTH-13,
[InlineData(null)] // the user secret was never set
[InlineData("")]
[InlineData("1234567")]
public void Api_does_not_start_without_a_valid_initial_password(string? invalidPassword) // AUTH-13, AUTH-16
public void Api_does_not_start_without_a_valid_initial_password(string? invalidPassword) // AUTH-16; previous plan, see decision 21 (was AUTH-13)
{
using var invalid = factory.WithWebHostBuilder(builder =>
builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary<string, string?>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class AdminFirstAccessApiTests(AuthApiFactory factory) : IClassFix
private const string ChangeRequired = "Por segurança, a senha inicial precisa ser trocada no primeiro acesso.";

[Fact]
public async Task Admin_with_the_initial_password_signs_in_with_must_change_password() // AUTH-13, AUTH-14
public async Task Admin_with_the_initial_password_signs_in_with_must_change_password() // AUTH-14, on the Admin by the previous plan, see decision 21
{
await AuthTestSupport.ResetAdminAsync(factory.Services);
using var client = factory.CreateHttpsClient();
Expand All @@ -35,7 +35,7 @@ public async Task Admin_with_the_initial_password_signs_in_with_must_change_pass
}

[Fact]
public async Task Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpoint() // AUTH-14, AUTH-27
public async Task Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpoint() // AUTH-14, AUTH-27, on the Admin by the previous plan, see decision 21
{
await AuthTestSupport.ResetAdminAsync(factory.Services);
using var client = factory.CreateHttpsClient();
Expand All @@ -61,7 +61,7 @@ public async Task Admin_with_the_initial_password_can_use_me_and_sign_out()
}

[Fact]
public async Task Change_password_returns_new_tokens_and_unlocks_other_endpoints() // AUTH-14, USR-34, D3 of #7
public async Task Change_password_returns_new_tokens_and_unlocks_other_endpoints() // AUTH-14, D3 of #7; activation time is the previous plan, see decision 21 (was USR-34)
{
await AuthTestSupport.ResetAdminAsync(factory.Services);
using var client = factory.CreateHttpsClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public User AddActiveUser(string login, string password)
return user;
}

/// <summary>The Admin as the seeder creates it, with the initial password that must be replaced (AUTH-13).</summary>
/// <summary>The Admin as the seeder creates it, with the initial password that must be replaced (previous plan, see decision 21).</summary>
public User AddAdminWithInitialPassword(string initialPassword)
{
var admin = User.CreateAdmin(EmailAddress.Create("admin@example.com").Value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public async Task Changing_the_password_ends_every_session_and_starts_a_new_one(
}

[Fact]
public async Task Changing_the_initial_password_fills_the_admin_activation_time() // USR-34
public async Task Changing_the_initial_password_fills_the_admin_activation_time() // previous plan, see decision 21 (was USR-34)
{
var bed = new AuthTestBed();
var admin = bed.AddAdminWithInitialPassword("senha-inicial");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public async Task Wrong_password_is_refused_with_the_same_error_as_an_unknown_lo
}

[Fact]
public async Task Pending_account_is_refused_as_invalid_credentials() // AUTH-02, AUTH-08
public async Task Pending_account_is_refused_as_invalid_credentials() // AUTH-08; the pending status is the previous plan, see decision 21
{
var bed = new AuthTestBed();
var user = bed.AddPendingUser("bruno.lima");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public void EmailAddress_with_valid_format_is_accepted() // USR-07
}

[Fact]
public void EmailAddress_left_blank_is_rejected() // USR-07
public void EmailAddress_left_blank_is_rejected() // previous plan, see decision 21: the e-mail became optional (USR-07)
{
var result = EmailAddress.Create("");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public void Normalized_display_name_ignores_case_accents_and_surrounding_spaces(
}

[Fact]
public void New_user_is_pending() // AUTH-02
public void New_user_is_pending() // previous plan, see decision 21 (was AUTH-02)
{
var user = TestData.NewUser();

Expand All @@ -62,7 +62,7 @@ public void New_user_gets_a_version_7_id() // CNV-04
}

[Fact]
public void Activating_a_pending_user_makes_it_active_with_the_activation_date() // AUTH-02
public void Activating_a_pending_user_makes_it_active_with_the_activation_date() // previous plan, see decision 21 (was AUTH-02)
{
var user = TestData.NewUser();
var now = new DateTimeOffset(2026, 9, 12, 13, 5, 44, TimeSpan.Zero);
Expand All @@ -75,7 +75,7 @@ public void Activating_a_pending_user_makes_it_active_with_the_activation_date()
}

[Fact]
public void Activating_an_active_user_is_refused_as_invalid_link() // AUTH-23
public void Activating_an_active_user_is_refused_as_invalid_link() // previous plan, see decision 21 (was AUTH-23)
{
var user = TestData.NewUser();
user.Activate(new DateTimeOffset(2026, 9, 12, 13, 5, 44, TimeSpan.Zero));
Expand All @@ -89,7 +89,7 @@ public void Activating_an_active_user_is_refused_as_invalid_link() // AUTH-23
}

[Fact]
public void Activating_an_inactive_user_is_refused_as_invalid_link() // AUTH-23, AUTH-21
public void Activating_an_inactive_user_is_refused_as_invalid_link() // previous plan, see decision 21 (was AUTH-23)
{
var user = TestData.NewUser();
user.Deactivate(Guid.CreateVersion7(), new DateTimeOffset(2026, 9, 20, 17, 41, 2, TimeSpan.Zero));
Expand Down Expand Up @@ -170,7 +170,7 @@ public void Reactivating_a_user_who_had_a_password_makes_it_active() // USR-18
}

[Fact]
public void Reactivating_a_user_who_never_had_a_password_makes_it_pending() // USR-18
public void Reactivating_a_user_who_never_had_a_password_makes_it_pending() // previous plan, see decision 21 (USR-18 no longer has this case)
{
var user = TestData.NewUser();
user.Deactivate(Guid.CreateVersion7(), new DateTimeOffset(2026, 9, 20, 17, 41, 2, TimeSpan.Zero));
Expand Down Expand Up @@ -234,7 +234,7 @@ public void Assigning_profiles_replaces_them_and_ignores_repeated_ids()
}

[Fact]
public void Admin_has_the_fixed_system_values_and_the_configured_email() // USR-23, USR-24, USR-32
public void Admin_has_the_fixed_system_values_and_the_configured_email() // USR-23, USR-24
{
var email = EmailAddress.Create("admin@example.com").Value;

Expand Down Expand Up @@ -278,7 +278,7 @@ public void Admin_profiles_cannot_change() // USR-24, USR-28
}

[Fact]
public void Completing_the_first_access_fills_activated_at() // USR-34
public void Completing_the_first_access_fills_activated_at() // previous plan, see decision 21 (was USR-34)
{
var admin = TestData.NewAdmin();
var now = new DateTimeOffset(2026, 9, 30, 8, 0, 0, TimeSpan.Zero);
Expand All @@ -290,7 +290,7 @@ public void Completing_the_first_access_fills_activated_at() // USR-34
}

[Fact]
public void Completing_the_first_access_again_keeps_the_first_time() // USR-34
public void Completing_the_first_access_again_keeps_the_first_time() // previous plan, see decision 21 (was USR-34)
{
var admin = TestData.NewAdmin();
var first = new DateTimeOffset(2026, 9, 30, 8, 0, 0, TimeSpan.Zero);
Expand Down
Loading
Loading