# Platform administration and RBAC

## Database design

`roles` is scoped by `org_id`: a NULL scope is platform-wide and a populated scope belongs to one company. `permissions` is the stable module/action catalogue; `role_permissions` and `user_roles` are the normalized many-to-many joins. The legacy `users.role` enum remains as a compatibility projection. The migration creates equivalent system roles for every organization and copies every existing assignment into `user_roles`, so existing access is retained.

Platform owners authenticate from `platform_admins`, never `users`. Their roles use `platform_admin_roles`; sensitive actions write `platform_audit_log`. Companies retain their existing logo fields and gain relational `company_type`/`consulting_org_id`, account state, license dates and licensed capacity. `organization_branding` can hold a client-specific consulting mark; the company portal falls back to the consulting company's main logo.

## Access and license rules

Normal company login and existing sessions are rejected for suspended or disabled companies and for licenses whose end date has passed. “Expiring soon” means 30 days or fewer and remains accessible with a warning. Platform login is separate and remains available to remedy company state. Active-user count is always calculated from active `users` rows. The backend is authoritative; frontend checks only shape navigation.

Consulting-company administrators enter from their normal company login and use **Managed companies portal** in the account menu. Their API scope contains only their own consulting firm and directly managed clients. They cannot edit the parent firm's global license, access unrelated companies, or move a client beyond the consulting firm's license dates. `max_client_companies` is controlled by the system owner and caps enrollment. Managed clients display the consulting firm's logo in the normal application header; standalone companies render no secondary-logo placeholder.

The system owner creates consulting administrators from **Companies & licenses → portal-users button** on a consulting-firm row. A portal user requires an active user record, an administrative company role, and the explicit `can_access_consulting_portal` flag. Creation also respects the consulting company's `max_users` license. Portal access can be activated, deactivated, or removed from the same dialog, and the last active portal administrator is protected from accidental removal.

## Setup and migration

1. Back up MySQL.
2. Configure `DB_*` and a random 32+ byte `JWT_SECRET` in `.env`.
3. Run `npm run db:migrate`. It is additive and idempotent.
4. Temporarily set `PLATFORM_ADMIN_EMAIL`, `PLATFORM_ADMIN_NAME`, and a unique 12+ character `PLATFORM_ADMIN_PASSWORD`.
5. Run `npm run db:seed-platform-admin`, then remove `PLATFORM_ADMIN_PASSWORD` from the environment.
6. Build with `npm run build`, then open `/platform/login`.

For a local showcase, `npm run db:seed-platform-demo` adds an idempotent portfolio covering active, expiring, expired, suspended, disabled, standalone, consulting-firm, and consulting-managed states. Its generated dummy users have unrecoverable random passwords and exist only to demonstrate licensed-capacity counts.

## API summary

All calls are under `/api`; Bearer tokens are held in tab-scoped session storage.

- `POST /platform/login`, `POST /platform/logout`, `GET /platform/session`, `POST /platform/password`
- `GET|POST /platform/roles`, `POST /platform/roles/:id`, `POST /platform/roles/:id/delete`
- `GET|POST /platform/companies`, `POST /platform/companies/:id`, `POST /platform/companies/:id/logo`
- `GET|POST /admin/roles`, `POST /admin/roles/:id`, `POST /admin/roles/:id/delete`
- `POST /admin/users/:id/roles` with comma-separated `role_ids`

Company listing accepts `q`, `type`, `status`, `limit`, and `offset`. Role updates accept comma-separated permission codes. Validation errors use 422, unauthenticated requests 401, permission refusals 403, unsafe role deletion 409, and login throttling 429.

## Verification

Run `npm test`, `npm run typecheck`, and `npm run build`. With a disposable database, run `npm run db:migrate` twice (the second run must report existing objects), seed a platform owner, and verify both login namespaces independently.
