Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.env
flossk-ms/FlosskMS.API/appsettings.Docker.json
connect-to-remote.sh
node_modules/
dist/
.angular/
*.log
185 changes: 0 additions & 185 deletions DOCKER.md

This file was deleted.

68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# FLOSSK CRM

Membership and operations platform for [FLOSS Kosova](https://flossk.org) — members, projects,
events, inventory, elections, certificates, courses, a point of sale, purchase approvals and
door access control.

**Stack:** ASP.NET Core (.NET 10) + PostgreSQL backend, Angular 20 (PrimeNG) frontend,
ClamAV, Caddy, Docker Compose.

## Run it (Docker)

Create a `.env` in the repo root:

```dotenv
POSTGRES_PASSWORD=change-me
DOMAIN=localhost
JwtSettings__Secret=<random string, 32+ chars>
JwtSettings__Issuer=FlosskMS
JwtSettings__Audience=FlosskMSClient
```

Then:

```bash
docker compose -f docker-compose.prod.yml -f docker-compose.local.yml \
up -d --build postgres clamav api frontend caddy
```

- Frontend: http://localhost:8081
- API: http://localhost:8080
- Seeded admin: `daorsahyseni@gmail.com` / `P@ssword123`

> Don't run `up` without listing services — `docker-compose.prod.yml` also has a Jenkins
> container that binds port 8080.

## Run it (local dev)

```bash
docker compose up postgres clamav # infra only

cd flossk-ms && dotnet run --project FlosskMS.API # API → :5267, Swagger at /swagger
cd flossk-webclient && npm install && npm start # web → :4200
```

## Layout

```
flossk-ms/ .NET solution
FlosskMS.API/ controllers, SignalR hubs, Program.cs
FlosskMS.Business/ services, DTOs, domain events
FlosskMS.Data/ EF Core context, entities, migrations, seeder
FlosskMS.Tests/ xUnit
flossk-webclient/ Angular 20 app
docs/ feature docs
docker-compose.*.yml dev / prod / local overrides
Jenkinsfile CI: test + deploy to root.flossk.org
```

## Notes

- The API runs EF Core migrations and seeds roles + the admin user on every start.
- Add a migration: `dotnet ef migrations add <Name> --project FlosskMS.Data --startup-project FlosskMS.API`
- Tests: `dotnet test flossk-ms/FlosskMS.slnx`
- Config is all environment variables (`__` = nested key); `SmtpSettings__*` and
`VapidSettings__*` are optional and those features no-op when unset.
- Door access control: see [`docs/ACCESS_CONTROL.md`](docs/ACCESS_CONTROL.md).
- `docker-compose.dev.yml` is infra-only; `docker-compose.prod.yml` is the full stack;
`docker-compose.local.yml` just adds host port mappings for local use.
14 changes: 14 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Local-only overrides for docker-compose.prod.yml
# Usage: docker compose -f docker-compose.prod.yml -f docker-compose.local.yml up -d --build postgres clamav api frontend caddy
services:
postgres:
ports:
- "5433:5432"

api:
ports:
- "8080:8080"

frontend:
ports:
- "8081:80"
121 changes: 121 additions & 0 deletions docs/ACCESS_CONTROL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Door Access Control

Manages physical doors, the ESP32 controllers that drive their locks, and the
credentials that open them — plain **NFC cards** and **Aliro / Apple Home Key**
credentials — with an explicit **accept / decline** gate and a full audit log.

It extends the existing RFID card feature: every credential is still a
`UserRfidCard` row, now with a `CredentialType`, a lifecycle `Status`, Matter/Aliro
slot numbers (`UserNumber` = *SetUser*, `CredentialNumber` = *SetCredential*), and
per-door grants.

## Concepts

| Entity | Purpose |
|--------|---------|
| **AccessDoor** | A physical door. `IsActive=false` → nothing opens it. |
| **AccessDevice** | A registered ESP32. Holds its `IpAddress`, `Port`, a shared `Secret`, and `IsAllowed`. Used both to authenticate inbound webhook calls and as the target for outbound provisioning / "grab data". |
| **UserRfidCard** (credential) | `CredentialType` = `NfcCard` \| `HomeKey`. `Status` = `Pending` → `Active` → `Declined` / `Disabled` / `Revoked`. `AllDoors` or explicit `AccessDoorGrant` rows. |
| **Member code** | `ApplicationUser.MemberCode` — a stable random badge id like `FOSS-K7M2QX9P` (prefix + 8 chars, unambiguous alphabet). Assigned on creation by a SaveChanges interceptor (every creation path) and back-filled for existing members by `DbSeeder` on startup. Shown on Profile, the Users list (sortable column), and each credential row; also a `memberCode` JWT claim. The Matter/Aliro *SetUser* slot is a separate per-member integer allocated at first provisioning. |
| **AccessLog** | Every unlock, denial, credential change, provisioning push and device event. |

### Credential lifecycle

```
assign ─▶ Pending ──accept──▶ Active ──▶ door opens
│ ▲ (HomeKey: must be provisioned first)
decline │ disable/enable
▼ │
Declined / Disabled / Revoked ─▶ door stays locked
```

- **NFC card**: assign with the card UUID → accept → done.
- **Home Key**: assign → **Provision Home Key** (pushes *SetUser* + *SetCredential*
to the door's ESP32s) → accept. Access is refused until `HomeKeyProvisionedAt`
is set **and** `Status = Active`.

## REST API

All management endpoints require the **Admin** role.

### Doors — `/api/AccessDoors`
`GET` · `GET /{id}` · `POST` · `PUT /{id}` · `DELETE /{id}`

### Devices — `/api/AccessDevices`
`GET [?doorId=]` · `GET /{id}` · `POST` · `PUT /{id}` · `DELETE /{id}`
`POST /{id}/sync` — pull state from the device (`GET http://{ip}:{port}/state`).

`POST` returns the generated `secret` **once**. `PUT { "rotateSecret": true }`
issues a new one (also returned once).

### Credentials — `/api/RfidCards`
Existing endpoints unchanged, plus:

| Method | Path | Effect |
|--------|------|--------|
| POST | `/credentials/assign` | Create a `Pending` credential for a member. |
| PATCH | `/{id}/accept` | → `Active`; pushes *SetUser* + *SetCredential*. |
| PATCH | `/{id}/decline` `{ reason? }` | → `Declined`; removes it from devices. |
| PATCH | `/{id}/disable` / `/{id}/enable` | Toggle without losing the assignment. |
| POST | `/{id}/provision-homekey` | Push the Home Key to the member's device. |
| PUT | `/{id}/doors` `{ allDoors, doorIds[] }` | Set which doors it opens. |
| GET | `/{id}/logs` | Access-log entries for that credential. |

### Access logs — `/api/AccessLogs`
`GET ?page=&pageSize=&doorId=&rfidCardId=&userId=&eventType=&granted=&dateFrom=&dateTo=`

## Webhook — what the ESP32 calls

Base: `POST /api/access/...` · Header: `X-Device-Key: <the device secret>`
Auth = the secret matches an `AccessDevice` row that is `IsAllowed`. If that device
has **`EnforceIpCheck`** enabled, the request's source IP must also equal its
`IpAddress` (IPv4-mapped IPv6 is normalised). Leave `EnforceIpCheck` off when the
API is behind a proxy/NAT that rewrites the client address.

### `POST /api/access/verify`
```json
{ "uuid": "04:A1:B2:C3:D4:E5:F6", "credentialType": "NfcCard", "metadata": "reader=1" }
```
→ `200`
```json
{ "granted": true, "reason": "granted", "userName": "Ada Byron", "doorId": "…" }
```
`granted:false` reasons: `unknown device`, `door disabled`, `unknown credential`,
`credential not assigned`, `awaiting acceptance`, `credential declined`,
`credential disabled`, `credential revoked`, `home key not provisioned`,
`no access to this door`. Every call writes an `AccessLog`.

### `POST /api/access/event`
```json
{ "eventType": "Unlock", "uuid": "…", "firmwareVersion": "1.4.2", "metadata": "…" }
```
`eventType` ∈ `Unlock`, `DoorForced`, `DoorHeldOpen`, `DeviceHeartbeat`, … Sending a
heartbeat every minute keeps the device shown as **online** in the UI.

## What the ESP32 firmware needs to implement

1. On card / phone tap → `POST /api/access/verify` with the UUID; open the strike
only if `granted` is true.
2. `POST /api/access/event` for door-open / tamper / heartbeat.
3. A local `GET /state` returning JSON (any shape) for the "grab data" button.
4. Accept `POST /setUser`, `POST /setCredential`, `POST /removeCredential`
(JSON body, `X-Device-Key` header) to keep an **offline allow-list** so the
door still works if the network is down. Bodies:
- `setUser`: `{ op, userNumber, userName, userStatus }`
- `setCredential`: `{ op, userNumber, credentialNumber, credentialType, credentialData, userStatus }`
- `removeCredential`: `{ op, userNumber, credentialNumber, credentialType, credentialData }`

## UI

**Dashboard → Access Control** (Admin only):
Credentials · Doors · ESP32 Devices · Access Logs.

## Notes / TODO

- Provisioning is best-effort: if a device is unreachable the credential still
changes state and an `AccessLog` records the failure; re-run *accept* or *provision*
once the device is back.
- The IP allow-list is skipped when the API sits behind a proxy that hides the
client IP and no `X-Forwarded-For` is present — the secret is always required.
- `AccessProvisioningService` is the single seam for real lock integration; swap it
for a Matter-controller or MQTT implementation without touching the rest.
Loading