Skip to content

Latest commit

 

History

History
464 lines (346 loc) · 17 KB

File metadata and controls

464 lines (346 loc) · 17 KB

User authentication and tenant authorization

This document defines the smallest authentication design needed by the current API.

The central rule is:

The token identifies the user; the database determines the organization; the organization scopes every tenant-owned operation.

Authentication answers “who made this request?”. Authorization answers “may that user operate on this organization’s data?”.

Scope

The initial implementation provides:

  • public organization registration with one initial user;
  • password-based login;
  • short-lived JWT access tokens;
  • a request-scoped authenticated actor;
  • tenant identity derived from authentication rather than GraphQL input;
  • tenant-scoped repository lookups.

Every user belongs to exactly one organization. Roles and permissions are not part of this first implementation.

Request flow

Authorization: Bearer <token>
             ↓
GraphQL context
  - verifies the token
  - loads the user by token subject
  - creates AuthenticatedActor
             ↓
Resolver
  - requires an actor for protected operations
  - calls the use case
             ↓
Use case
  - obtains organizationId from the actor
             ↓
Repository
  - scopes tenant-owned queries by organizationId

The JWT contains the stable user ID in sub. It does not need to contain organizationId. Loading the user on each request makes current organization membership the source of truth.

File structure

src/
├── domain/
│   └── User.ts
├── application/
│   ├── auth/
│   │   └── AuthenticatedActor.ts
│   ├── ports/
│   │   ├── UserRepository.ts
│   │   ├── PasswordHasher.ts
│   │   ├── AccessTokenService.ts
│   │   └── OrganizationRegistrationStore.ts
│   └── use-cases/
│       ├── RegisterOrganization.ts
│       └── Login.ts
├── infra/
│   ├── auth/
│   │   ├── Argon2PasswordHasher.ts
│   │   └── JoseAccessTokenService.ts
│   └── database/
│       ├── prisma-user-repo.ts
│       └── prisma-organization-registration-store.ts
└── delivery/
    └── graphql/
        ├── context.ts
        └── modules/
            └── auth.ts

tests/
├── application/
│   └── use-cases/
├── delivery/
│   └── graphql/
└── database/

Application tests can define small fakes directly in the test file. There is no need to build a shared in-memory database or reproduce Prisma transactions in memory.

Application boundaries

Persistent user

User is the persistent identity entity:

type UserState = {
  id: string;
  organizationId: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
};

Raw passwords and registration input types do not belong in User.ts. They belong to the registration use case. The Prisma-generated user is mapped to this entity by PrismaUserRepository.

Authenticated actor

AuthenticatedActor is trusted request state, not a persistent entity or GraphQL input:

type AuthenticatedActor = {
  userId: string;
  organizationId: string;
};

Protected use cases receive it explicitly:

execute(actor: AuthenticatedActor, input: CreateStockMovementInput)

They do not receive the entire GraphQL context.

Authentication ports

The application owns small interfaces for external security operations:

interface PasswordHasher {
  hash(password: string): Promise<string>;
  verify(hash: string, password: string): Promise<boolean>;
}

type AccessTokenClaims = {
  userId: string;
};

interface AccessTokenService {
  create(claims: AccessTokenClaims): Promise<string>;
  verify(token: string): Promise<AccessTokenClaims>;
}

Argon2 and JOSE remain infrastructure details behind these ports.

JWT_SECRET is required runtime configuration. The composition root validates it at startup and injects it into JoseAccessTokenService; the infrastructure adapter does not read process.env directly. HS256 requires a strong signing secret, so shared environments use at least 32 cryptographically random bytes.

The composition root constructs the infrastructure implementations and passes them to UseCases. PasswordHasher and AccessTokenService are supplied separately because they are security services, while OrganizationRegistrationStore remains part of the Repositories dependency as repos.registrations:

new UseCases({
  repos,
  passwordHasher,
  accessTokenService,
});

This was a deliberate simplification: all persistence adapters are grouped in one object for use-case dependency wiring, even though OrganizationRegistrationStore is a transactional persistence port rather than a conventional single-entity repository. RegisterOrganization still receives the narrow store interface directly; only the composition-level grouping is shared. A framework container might otherwise perform this dependency wiring implicitly.

Registration

RegisterOrganization accepts organization details, initial-user email, and the raw password. It:

  1. validates and normalizes input;
  2. hashes the password;
  3. creates the Organization and User entities;
  4. asks OrganizationRegistrationStore to persist both atomically;
  5. returns a small result containing the new IDs.

Normalization must be consistent during registration and login, for example email.trim().toLowerCase().

The persistence boundary is:

interface OrganizationRegistrationStore {
  register(organization: Organization, initialUser: User): Promise<void>;
}

Its Prisma implementation uses one nested organization.create() with users.create. Prisma makes that nested write atomic: either both records are created or neither is.

Database unique constraints are authoritative. An application pre-check may improve the normal error path, but it cannot prevent concurrent registrations. Prisma unique-constraint failures must also become a controlled duplicate-email error.

Registration is exposed through a public GraphQL mutation in delivery/graphql/modules/registration.ts:

The resolver validates the input with Zod and calls context.useCases.registerOrganization.execute(input). It does not require an authenticated actor, hash passwords, or call Prisma directly. Registration returns IDs; clients use the separate login mutation to obtain an access token.

Registration is the only public organization provisioning path. The former unrestricted createOrganization mutation has been removed.

Login

Login:

  1. normalizes the email;
  2. loads the user with UserRepository.findByEmail();
  3. verifies the password hash;
  4. returns the same public credentials error for an unknown email and a wrong password;
  5. issues an access token with sub = user.id.

The token must not contain passwords or other sensitive user data.

GraphQL context

The current static context becomes a per-request asynchronous context factory. It:

  1. reads and parses Authorization: Bearer <token>;
  2. verifies the signature, algorithm, expiry, issuer, and audience;
  3. loads the user identified by sub;
  4. creates AuthenticatedActor from that user;
  5. otherwise an absent header produces actor: null; malformed/invalid tokens and deleted users should produce UNAUTHENTICATED.

A small requireAuthenticatedActor(context) delivery helper returns the actor or throws a controlled UNAUTHENTICATED GraphQL error. Resolvers do not parse tokens individually.

Tenant authorization

GraphQL clients must not choose tenant identity. Tenant-owned inputs therefore omit organizationId:

input CreateStockMovementInput {
  storageAreaId: ID!
  # other operation data
}

The use case derives it from actor.organizationId.

Repositories for tenant-owned resources use both values:

findById(organizationId, id);

They return the same not-found result when a resource is absent or belongs to another organization. Existing composite PostgreSQL constraints remain defence in depth for relational writes; they do not replace authorization of reads.

Identity lookups are exceptions to tenant scoping: login finds a user by globally unique email, and context loads a user by globally unique ID.

Public errors

The initial public contract needs:

  • UNAUTHENTICATED for missing, invalid, or expired credentials;
  • NOT_FOUND for an absent or foreign-tenant resource;
  • a controlled invalid-credentials error for login;
  • a controlled duplicate-email error for registration.

FORBIDDEN becomes useful when the application introduces an actual permission rule, such as administrator-only user invitations.

Tests

See suggested tests

In-memory tests prove application decisions. PostgreSQL tests prove Prisma queries, unique constraints, foreign keys, and rollback.

Tenant authorization implementation plan

Registration, login, token issuance, and the per-request GraphQL context are already in place. Tenant authorization should now be introduced one complete operation at a time. Each operation should be green across the delivery, application, repository, and test layers before starting the next one.

Do not protect the entire /graphql HTTP endpoint. The schema contains both public and protected operations:

  • registerOrganization, login, and health remain public;
  • tenant-owned mutations and queries require an authenticated actor.

Protected-operation tests use authenticated fixtures and do not create tenants through a second provisioning mutation.

Phase 1: Prepare the GraphQL authentication test seam

  1. Add requireAuthenticatedActor(context) to the GraphQL delivery layer. It returns context.actor or throws the controlled UNAUTHENTICATED error.
  2. Call this helper before parsing the input of a protected resolver. This makes missing authentication the first failure and avoids exposing validation details to an unauthenticated caller.
  3. Extend the GraphQL test helper so a request can include headers.
  4. Let resolver tests supply a small fake token service and user repository that produce a known actor from a known token.
  5. Keep token verification details in the existing context and JOSE tests. Resolver tests only need to prove that a protected resolver requires and forwards the actor.

The test fixture only needs to represent this flow:

Authorization: Bearer valid-token
             ↓
AccessTokenService.verify()
             ↓
userId
             ↓
UserRepository.findById()
             ↓
AuthenticatedActor

It does not require a shared in-memory database, registration, password hashing, or a real JWT.

Phase 2: Protect CreateProduct end to end

Start with CreateProduct rather than CreateStockMovement. Product creation is already wired through GraphQL, the application layer, and both repository implementations, so it provides the smallest complete example of the pattern.

Application boundary

  1. Define a use-case-specific input that omits tenant identity:

    type CreateProductInput = Omit<ProductInput, "organizationId">;
  2. Change the protected use-case signature to:

    execute(actor: AuthenticatedActor, input: CreateProductInput)
  3. Construct the product with organizationId: actor.organizationId.

  4. Keep organizationId on the domain entity. It is the untrusted delivery and use-case input that must omit it, not the domain state.

  5. Update application tests to pass a plain AuthenticatedActor fixture directly. Do not create a GraphQL context or token in use-case tests.

  6. Remove the use-case test for an empty client-supplied organization ID because that field is no longer part of the input.

Tenant-scoped supplier lookup

  1. Change the supplier lookup contract to include tenant identity:

    findById(organizationId: string, id: string);
  2. Update the in-memory adapter to check both values. It must not merely accept organizationId and then ignore it.

  3. Update the Prisma adapter to query through the existing compound unique key for (organizationId, id).

  4. Make CreateProduct pass actor.organizationId to the lookup.

  5. Return the same ProductSupplierNotFoundError when the supplier is absent or belongs to another organization.

  6. Remove ProductOrganizationIsNotAcceptedError; a correctly scoped repository never returns a foreign supplier.

  7. Add a Prisma repository test proving that a supplier cannot be loaded using another organization's ID.

GraphQL boundary

  1. Remove organizationId from the GraphQL CreateProductInput definition.

  2. Remove organizationId from the corresponding Zod schema.

  3. Require the actor in the resolver and pass it explicitly to CreateProduct.

  4. Keep organizationId on the returned Product type; clients may observe the tenant identity but may not choose it.

  5. Add resolver tests proving that:

    • a request without credentials returns UNAUTHENTICATED;
    • the use case is not called without an actor;
    • a valid request calls the use case with the authenticated actor;
    • the use-case input does not contain organizationId;
    • attempting to submit organizationId is rejected by GraphQL;
    • missing and foreign-tenant suppliers both become NOT_FOUND.

Complete and commit the whole product slice before changing another operation.

Phase 3: Protect supplier and storage-area creation

Apply the established pattern separately to CreateSupplier and then CreateStorageArea:

  1. Add AuthenticatedActor to the use-case signature.
  2. Remove organizationId from the use-case input.
  3. Derive organizationId from the actor when constructing the domain entity.
  4. Remove organizationId from the GraphQL input and Zod schema.
  5. Require and forward the actor in the resolver.
  6. Add missing-authentication and actor-forwarding resolver tests.
  7. Add application tests proving that the saved entity uses the actor's organization.

The context builds an actor from a persisted user whose organization membership is protected by a database foreign key. These creation use cases therefore do not need to load the organization merely to rediscover that it exists.

Finish and commit one operation before starting the next.

Phase 4: Protect and expose stock movement creation

Once the smaller slices establish the pattern:

  1. Add CreateStockMovement to the composed UseCases object and GraphQL context if it is not yet wired.
  2. Add its GraphQL module and mutation without exposing organizationId as input.
  3. Change the use case to accept AuthenticatedActor and derive the movement organization from it.
  4. Scope the storage-area lookup by (actor.organizationId, storageAreaId).
  5. Load or validate products only within actor.organizationId.
  6. Assign the actor's organization to both the stock movement and every movement line.
  7. Return the same NOT_FOUND result for absent and foreign storage areas or products.
  8. Test mixed-tenant movement lines and verify that no movement is persisted when any referenced product is unavailable to the actor.

Phase 5: Apply tenant scoping to future reads and writes

For every tenant-owned query, update, and deletion:

  1. Require an authenticated actor at the resolver.
  2. Pass the actor explicitly to the use case.
  3. Scope repository operations by both organizationId and resource ID.
  4. Do not load a resource globally and authorize it afterwards when the database can perform the scoped lookup directly.
  5. Use NOT_FOUND for both missing and foreign resources.
  6. Scope updates and deletions as well as reads. Database constraints alone do not authorize an operation.

Phase 6: Remove the legacy organization creation path (completed)

After every protected-operation test has authenticated setup:

  1. Remove the public createOrganization mutation.
  2. Remove CreateOrganization from the composed use cases and GraphQL context.
  3. Remove its obsolete error mappings and tests.
  4. Keep the Organization GraphQL output type if other schema fields use it.
  5. Confirm that registerOrganization is the only public organization provisioning path.

Verification checkpoint

Run the focused tests while developing each layer, then run all checks before committing a completed operation:

npm run typecheck
npm test
npm run lint
npm run test:database

The database suite requires its PostgreSQL test-container environment. Unit and GraphQL tests should continue to use narrow fakes rather than reproducing Prisma or transactions in memory.

Not part of the first implementation

Defer these until a concrete requirement needs them:

  • shared in-memory database;
  • generic Unit of Work;
  • roles and permission frameworks;
  • refresh-token rotation and revocation;
  • email verification and password reset;
  • organization invitations;
  • MFA, passkeys, or OAuth providers;
  • PostgreSQL row-level security;
  • asymmetric JWT keys and key rotation.

Rate limiting and abuse protection are required before exposing public authentication to untrusted internet traffic, but they do not need to block the initial local learning implementation.