# PRD — silicon_security

Evidence date: 2026-10-03.

Source: `git@bitbucket.org:overdrive/silicon_security.git`, `main` at `514489a` (2025-12-15).

There is no prior PRD in Silicon Ark. Requirements below are recovered from `docs/README.md`, `docs/CHANGELOG.md`, `scope/Task_viewer_user_profile.md`, routes, controllers, models, filters, and seeders. Provenance is labelled. Credential values are omitted.

The repository name is `silicon_security`. The product name written in the source is **Admin Overdrive**. See `project-plan.md` for that naming discrepancy.

## Product purpose

**Documented.** `docs/README.md` describes an Admin Overdrive starter with authentication, authorisation, user registration, menu management, and a document management system: upload of PDF, JPG, and PNG with OCR, unified search (database, OCR text, and AI semantic search), categories and tags, role-based access with time restrictions, audit logging, and DataTables.

**Implemented.** Those document behaviours exist in `app/Controllers/Documents.php`, `app/Models/DocumentModel.php`, and `app/Neuron/DocumentRAG.php`. The admin shell (login, users, menus, settings, email queue) is older and also implemented.

**Unknown / Requires Confirmation:** who the production users are, which organisation owns the document corpus, and whether order, invoice, and product screens are still part of the product.

## Users and roles

Seeded role names in `app/Database/Seeds/UserRole.php`, inserted only when the name is absent:

| Order in seeder | Role name | How the running code treats it |
|---|---|---|
| 1 | Developer | Seeded user `admin@overdrive.co.za` is inserted with `role` = 1 before this seeder runs (`app/Database/Seeds/Users.php`). If the table was empty, role id 1 is Developer. |
| 2 | Admin | Named only in the seeder |
| 3 | Company Admin | Not referenced by the document role checks below |
| 4 | Company User | Not referenced by the document role checks below |
| 5 | System Admin | Named in docs and comments |
| 6 | System User | Named in docs and comments |
| 7 | Viewer | Named in docs and comments |

**Discrepancy — Requires Confirmation.** Document and user controllers treat session `role == 3` as Viewer (`app/Controllers/Documents.php`, `app/Models/DocumentModel.php`). The seeder's third role is Company Admin, and Viewer is seventh. If ids are assigned in seeder order starting at 1, role 3 is not Viewer. The docs (`docs/README.md`, changelog) speak of System Admin, System User, and Viewer by name and do not state numeric ids.

Menu visibility is not hardcoded to those names. `app/Filters/Authorization.php` loads `user_menu` by the first URL segment and checks `user_access` for the session role id. Sidebar rows come from `getMenu()` in `app/Helpers/menu_helper.php`.

The Users seeder inserts one account, username `admin@overdrive.co.za`, role 1, active 1, with a short hardcoded password passed through `password_hash`. The plaintext is not repeated here. Whether this account exists outside local seeding is **Unknown**.

`Welcome::setCompanyUsersAndAdminSessionVars` runs after login. What it stores for Company Admin and Company User was not fully traced. **Requires Confirmation** if company scoping is still a product rule.

## Systems the product talks to

1. MySQL through MySQLi (`app/Config/Database.php` default group). PHPUnit uses SQLite memory.
2. SMTP, keys `email.SMTPHost`, `email.SMTPUser`, `email.SMTPPass`, `email.SMTPPort`, `email.SMTPCrypto`, `email.fromEmail`, `email.fromName`. Used by `sendMail` and the 2FA message in `Welcome`.
3. OpenAI. `OPENAI_API_KEY`, `OPENAI_MODEL` (default `gpt-4` in `DocumentRAG`), embeddings via `EMBEDDINGS_API_KEY` or the OpenAI key, model default `text-embedding-3-small`, dimension default 1536.
4. Vector store selected by `VECTOR_STORE_TYPE` (default `memory`): `memory`, `mysql`, or `pinecone`. Pinecone needs `PINECONE_API_KEY` and `PINECONE_INDEX_URL`. If those are missing, the code logs a warning and uses `MemoryVectorStore`.
5. Tesseract and Poppler-style tools (`pdftotext`, `pdftoppm`) from `OcrLibrary`. `docs/README.md` changelog names `TESSERACT_PATH`. Host install is **Unknown**.
6. SonarQube from Bitbucket Pipelines on branch `main` only. Variables `SONAR_HOST_URL` and `SONAR_TOKEN`. No deploy step.
7. Slack. `app/Helpers/slack_helper.php` posts to a webhook URL. Route `slack` → `Slack::sendMessage` is excluded from the login filter.
8. Salesforce helper `salesforceConnect()` and Africa's Talking helper `smsMessage()` exist. No other `app/` file calls them. **Inferred** unused. **Requires Confirmation.**
9. AWS SDK, mPDF, and PhpSpreadsheet are Composer requirements. No call sites were found under `app/`.

Jira project `SI` and Confluence space `AO` are linked from `docs/README.md`. They were not read.

## Major workflows

### WF-1 — Sign in

**Implemented** in `Welcome::index`.

1. If `isLoggedIn` is already set, redirect to `home`.
2. Otherwise show `common/login` until `inputEmail` is present.
3. Load the user by username. If `active` is 0, flash "Your access has been revoked" and redirect to `/`.
4. If the username does not match, record login history status 1, reason "User does not exist", and flash a generic wrong-id message.
5. `password_verify` the submitted password. On failure, history status 1, reason "Wrong Password".
6. On success, `check2faEmailConfirmationDate`. If the returned flag is true, set session `username`, `role`, `isLoggedIn`, `userID`, empty `labelID`, update last login time, history status 2, and redirect to `redirectURL` or `home`.
7. If 2FA is not currently valid, `sendMail` the code to the username and redirect to `2fa-validation`. `confirmCode` checks the posted code.

The constructor checks the client IP against `blocked_users_list` and sets session `userBlocked` when `getBlockedIP` matches. The threshold that inserts a block was not confirmed in `LoginHistory` (that controller only writes history rows). **Requires Confirmation** where an IP becomes blocked.

Login filter exceptions (`app/Config/Filters.php`): `/`, `getLogin`, `register`, `organisations` and children, `tracking-pixel`, `show404`, `404`, `cronJobs`, `identity-unknown`, `store-api` and children, `2fa-validation`, `confirmCode`, `forgot-password`, `sendResetPasswordLink`, `reset-password`, `resetPassword`, `slack`, `email/test`.

`isGranted` skips those plus `blocked`, `home`, `Welcome/*`, `chatGPT/*`, and `rag/*`.

POST requests also run the CSRF filter (`$methods['post']`).

### WF-2 — Password reset and registration

**Implemented** routes: `forgot-password`, `sendResetPasswordLink`, `reset-password`, `resetPassword`, and `Welcome::register` / `registration`. Exact token lifetime was not read. **Requires Confirmation** for expiry and whether self-registration is enabled in production. The register path is outside `isLoggedIn`.

### WF-3 — Open a page inside the shell

**Implemented.**

1. `isLoggedIn` must be true or the response redirects to `/`.
2. Unless the URL is in the `isGranted` exception list, the first path segment must match a `user_menu.url`. No menu row redirects to `/`. A row the role cannot access redirects to `blocked`.
3. `Welcome::forbiddenPage` renders the blocked page. `TimeAccessFilter` also redirects to `blocked` with flash text "Access denied. You are outside your allowed access time window."

### WF-4 — Upload a document

**Implemented** in `Documents::uploadStep1` and `Documents::upload`. Route group `documents` also applies `timeAccess`.

1. Session role 3 is rejected with "You do not have permission to upload documents."
2. Step 1 accepts extensions `pdf`, `jpg`, `jpeg`, `png`. Other types return "Invalid file type. Allowed: PDF, JPG, PNG". Oversize files report PHP `upload_max_filesize` and `post_max_size`.
3. OCR runs on the temp file. Step 2 requires `title` (3–255 characters), `document_type_id` integer, and `temp_file_path`.
4. The file moves to `writable/uploads/documents/` with a `doc_` + unique + timestamp name.
5. Metadata stored includes title, type, reference number, description, path, mime, OCR text, category, tags, effective date, `created_by`.
6. `indexDocumentInRAG` runs when `OPENAI_API_KEY` is set. Empty key skips indexing and returns false.

Seeded document types (`DocumentTypes` seeder): Legislation (`legislation`), Labels (`labels`), Minutes (`minutes`).

### WF-5 — Find and open a document

**Implemented.**

- `documents/` renders `documents/index` with type and category lists.
- `documents/search` and `documents/list` back the search and DataTables list.
- `DocumentModel::getDocuments` and `searchDatabase` apply filters on type, category, title, reference, description, OCR text, and tags.
- Role 3 only receives documents they created or documents with a currently valid `user_documents` row. Other roles receive all active documents.
- `documents/view/{id}`, `display/{id}`, `download/{id}` load one document. Role 3 without access gets null from `getDocument`.
- Edit, update, and delete are rejected for role 3.

`documents/shareToViewer/{id}` shares one document from the view page.

### WF-6 — Share a document with a time limit

**Documented** in `scope/Task_viewer_user_profile.md`. **Implemented** in `UserDocumentModel` and `Users` profile actions.

Access types stored on `user_documents.access_type`:

| Type | Rule in `checkDocumentAccess` |
|---|---|
| `time_limit` | Deny if now is before `access_start_time` or after `access_end_time` |
| `login_active_time` | Deny if `users.last_login_time` is empty, or if now is later than last login plus `login_active_hours` hours |
| `permanent` | Allow while the row is `active` = 1 |
| anything else | Deny |

Assigning an existing active pair updates that row instead of inserting a second one. Revoke sets `active` = 0.

Profile navigation stores the selected user in session (`users/setSelectedUser`, `users/profile`) so the user id is not in the URL. **Documented** and **Implemented**.

Scope says only System Admin and System User may assign. The code that lists documents for sharing uses `role != 3` as "can see all documents". It does not check the role name. **Discrepancy** with the seeder ids. See business rule BR-04.

The scope lists as out of scope: email when a document is assigned, expiry notifications, and an access-analytics dashboard. Those were not found as features. **Documented** exclusion.

### WF-7 — User-level access window

Separate from per-document access.

**Implemented** in `UserModel::hasTimeRestriction` and `isWithinAccessTime`, applied by `TimeAccessFilter` to `documents*` before the controller runs.

- Flag `users.access_time_restricted` = 1 turns the check on.
- No `user_access_times` row means allow.
- Otherwise `days_of_week` is JSON of ISO day numbers (`date('N')`, Monday = 1) and `start_time` / `end_time` are clock times. Outside that window the filter redirects to `blocked`.

The filter comment says it is for Viewer. The code does not read the role. Any logged-in user with the flag is checked. **Discrepancy.**

The filter returns immediately when `isLoggedIn` is false and does not itself force login. Login is still required by the global `isLoggedIn` filter.

### WF-8 — Chat about documents

**Implemented** in `Chat::index`, `send`, `stream`, `getHistory`, `getConversations`.

- CSRF on `chat/send` and `chat/stream`.
- Empty session user id returns HTTP 401.
- A `conversation_id` that `ChatLogModel::getConversation` does not find for that user is discarded and a new conversation is started (`docs/CHAT_SECURITY_AUDIT.md`, 2025-11-11).
- Relevant documents and past memories are added to the prompt. Memory extraction runs when a conversation has at least two messages (`docs/IMPLEMENTATION_SUMMARY.md`).
- Cross-conversation semantic search keeps similarity at or above 0.3 and limits to five conversations. Document context updates are limited to five documents per conversation. Those numbers are **Documented** in the implementation summary.

`chatGPT` routes remain (`ChatGPT::index`, `ChatGPT::sent`) and are excluded from `isGranted`. How they differ from `Chat` is **Requires Confirmation**. Both exist.

### WF-9 — Email queue

**Documented** in `docs/EMAIL_QUEUE_AND_CRON_SETUP.md`. **Implemented** in `CronJobs`.

- Rows go into `sent_queue` (`Email::updateSentQue` / `mail_helper.php`).
- `GET cronJobs` is not behind login. It loads due rows from `cronjob_actions` and calls the named method.
- `sendScheduledEmails` sends rows with `processed` = 0 and a `send_date` that is due.
- `getAllUnsentEmails` sends rows with `processed` = 0 and a null `send_date`.
- The doc says each run processes up to `items_process_total` (default 10).

The documented example URL is `http://localhost/dms/cronJobs`. The route registered in code is `cronJobs` with no `dms` prefix. **Discrepancy — Requires Confirmation** of the real base URL.

`GET tracking-pixel` and `Tracking-pixel` hit `EmailPixelTracking::index` and are outside login. **Implemented.**

`GET email/test` is outside login. **Implemented** route. What the action sends was not fully read. Treat it as an open test endpoint until confirmed.

### WF-10 — System settings and monitoring

**Implemented** routes under `system_settings` for cron jobs, public holidays, email logs, sent queue, templates, login history, blocked users, general settings, ChatGPT settings, transport methods, and fault types.

`monitoring` shows usage and logs and can update alert thresholds (`ServerMonitoring`). `php spark monitor:system` loops, runs `top -b -n 1`, and writes a log when CPU or memory meets the threshold. It uses a pid file `writable/monitoring.pid` and `posix_kill`. The comment says sleep 10 seconds; the code sleeps 5. **Discrepancy** inside `MonitorSystemUsage.php`.

`rag/status` shows RAG configuration, can return JSON status, and can reindex. The group is excluded from `isGranted` but not from `isLoggedIn`.

## Functional requirements

### FR-001 — Authenticate with email and password

The system shows a login form. It accepts an email username and password, rejects inactive users, verifies the password hash, and either opens a session or starts email 2FA.

- Actor: anyone who can open `/`.
- Trigger: POST `GetLogin` / `Welcome::index`.
- Inputs: `inputEmail`, `inputPassword`.
- Output: session and redirect to `home`, or redirect to `2fa-validation`, or an error flash.
- Validation: email required to leave the login view. Inactive users are stopped before the password check.
- Errors: unknown user and wrong password share a generic flash message. History stores the distinct reason.
- Evidence: `app/Controllers/Welcome.php`, `app/Filters/Authentication.php`.
- Implementation: Implemented.
- Confidence: High.

### FR-002 — Email a 2FA code when the previous confirmation is stale

After a correct password, the system either completes login or emails a code and asks for it on `2fa-validation`.

- Actor: user with a valid password.
- Trigger: `check2faEmailConfirmationDate` returns a false flag and a code.
- Output: email to the username; form `ConfirmCode`.
- Evidence: `Welcome::index`, `Welcome::confirmCode`.
- Implementation: Implemented. The staleness window is inside `UserModel::check2faEmailConfirmationDate` and was not copied here.
- Confidence: Medium. Window length **Requires Confirmation**.

### FR-003 — Authorise pages by menu grant

A logged-in user can open a URL only when the first segment is a menu they are granted, or the URL is on the exception list. Otherwise they are sent to `/` or `blocked`.

- Evidence: `app/Filters/Authorization.php`, `app/Database/Seeds/UserMenu.php`.
- Implementation: Implemented.
- Confidence: High.
- Seeded menu URLs: `home`, `users`, `menuManagement`, `crudGenerator`, `media`, `system_settings`. Documents are not in that seeder. **Requires Confirmation** that a documents menu row is added outside the seeder (the changelog says a menu item was added).

### FR-004 — Upload PDF and image documents

A user whose session role is not 3 can upload PDF, JPG, JPEG, or PNG, attach metadata, store the file under `writable/uploads/documents/`, keep OCR text, and index it when OpenAI is configured.

- Evidence: `Documents::upload`, `Documents::uploadStep1`.
- Implementation: Implemented.
- Confidence: High for the role-id check. Low that role id 3 is the Viewer role in real data (FR-010).

### FR-005 — Search documents the caller is allowed to see

Search matches title, reference number, description, and OCR text, and can filter by type, category, and tags. Role 3 is limited to owned or validly assigned documents. Other roles see all active documents.

- Evidence: `DocumentModel::getDocuments`, `DocumentModel::searchDatabase`.
- Implementation: Implemented.
- Confidence: High for the code path. Role-id meaning: see FR-010.

### FR-006 — Assign a document with one of three access types

An operator can assign a document as `time_limit`, `login_active_time`, or `permanent`. Later reads honour that window. Revoke deactivates the row.

- Evidence: `UserDocumentModel`, `Users` profile routes, `scope/Task_viewer_user_profile.md`.
- Implementation: Implemented.
- Confidence: High.

### FR-007 — Enforce a weekly clock window on document routes

When `access_time_restricted` is 1 and a `user_access_times` row exists, `documents*` is refused outside the allowed days and clock times.

- Evidence: `TimeAccessFilter`, `UserModel::isWithinAccessTime`.
- Implementation: Implemented.
- Confidence: High.
- The filter does not check role name. Docs say the window is for Viewer. **Requires Confirmation.**

### FR-008 — Chat in the caller's own conversations

A logged-in user can send a chat message, stream a reply, and reload their history. A conversation id owned by someone else is not reused.

- Evidence: `app/Controllers/Chat.php`, `docs/CHAT_SECURITY_AUDIT.md`.
- Implementation: Implemented.
- Confidence: High.

### FR-009 — Send queued email from cron

`GET /cronJobs` processes due scheduled mail and unsent immediate mail, bounded per run.

- Evidence: `CronJobs`, `docs/EMAIL_QUEUE_AND_CRON_SETUP.md`.
- Implementation: Implemented.
- Confidence: High for the controller. The endpoint is not authenticated. Whether that is accepted is **Requires Confirmation**.

### FR-010 — Role names match the ids the document code uses

**Not established.**

Docs and the scope require System Admin and System User to manage documents and Viewer to be read-only with optional time limits. The code uses numeric role `3` as the read-only viewer. The seeder inserts Viewer last, not third.

- Implementation: Conflicting.
- Confidence: High that the conflict exists. The production id map is **Unknown / Requires Confirmation**.

### FR-011 — Orders, invoices, products, and fault finder

Routes and report views exist. Controllers and models named by those routes and by `Reports.php` are absent.

- Implementation: Not implemented in this tree.
- Confidence: High that the classes are missing. Whether the product still requires them is **Unknown / Requires Confirmation**.

## Important constraints

- Files land in `writable/uploads/documents/`, not in `public/`. Download goes through `Documents::download`.
- POST bodies are CSRF-checked globally. Some document and chat routes also set the csrf filter on the route.
- RAG does nothing useful without an OpenAI key. Default vector store is in-memory and does not survive a process restart (`DocumentRAG` comment: memory is for development).
- In `CI_ENVIRONMENT === 'development'`, `DocumentRAG` disables TLS peer verification for outbound calls. **Implemented.** Production behaviour depends on `CI_ENVIRONMENT`.
- `JwtLibrary` defaults the HMAC secret to the string `secret` when no secret is passed. No organisations controller was found to show it being used. **Requires Confirmation** before treating JWT organisation APIs as live.
- Seeded and environment secrets are in the git tree. They are a handling constraint, not a product feature.

## Integrations

See "Systems the product talks to". Direction summary:

| System | Direction | Trigger | Failure behaviour seen in code |
|---|---|---|---|
| MySQL | App reads and writes | Every request that uses a model | CodeIgniter database exceptions. Not catalogued per screen |
| SMTP | Outbound | 2FA, queued mail | Not fully traced. Queue row stays unprocessed if send does not mark it |
| OpenAI | Outbound | Chat, embeddings, RAG index | Missing key: RAG index returns false. Missing key in `DocumentRAG::provider` throws |
| Pinecone | Outbound | `VECTOR_STORE_TYPE=pinecone` | Missing key or URL: warning and memory store |
| SonarQube | CI outbound | Push to `main` | Pipeline has no other step |
| Slack webhook | Outbound | `Slack::sendMessage` or helper | Helper returns bool. Route is unauthenticated |

## Behaviour the overview over-claims

`overview.md` says the project "likely" uses MySQL, includes AWS, PDF generation, Excel, and XML processing because those libraries are in Composer. MySQL is confirmed by config. XML samples exist under `writable/`. AWS, mPDF, and PhpSpreadsheet usage inside `app/` was not found. Treat `overview.md` as a generated sketch, not as a requirement.
