Field Wizard — Information Security Policy
Product: Field Wizard (gofieldwizard.com) Owner: Founder (Cory Brown) Version: 1.0 Last reviewed: 2026-08-09 Next review: 2026-11-09 (quarterly), full rewrite review 2027-08-09
0. Status and honesty statement
Field Wizard is preparing for SOC 2 readiness. That means we are documenting and operating the controls a Type I/II audit would examine. It does not mean we have been audited, and we have no SOC 2 report, no ISO certification, and no third-party attestation of any kind. Anyone who tells you otherwise is wrong.
Field Wizard is built and operated by one person. This policy is written to be true for a one-person company, not to imitate a large security team. Where a control is not in place yet, it is listed in section 12 as a gap with a plan, not buried in aspirational language.
1. Scope
This policy covers:
- The Field Wizard web application and API (
gofieldwizard.com,
api.gofieldwizard.com).
- The production database and file storage behind them.
- The source code repository and CI pipeline.
- The third-party services listed in our subprocessor list.
- The founder's workstation and accounts used to administer the above.
It does not cover a customer's own devices, networks, or the third-party accounts they connect to Field Wizard (for example their own QuickBooks or Stripe account).
2. Data classification
We use four levels. The level drives how the data is stored, who can reach it, and how long we keep it.
| Level | What it is | Examples | Handling |
|---|---|---|---|
| Secret | Credentials and signing material | SECRET_KEY, database URI, Stripe/Resend/Anthropic API keys, OAuth client secrets | Environment variables only. Never in the repository, never in logs, never in an export. |
| Customer confidential | A customer's operational data | Jobs, form submissions, photos, signatures, invoices, estimates, customer/account records, employee records, GPS stamps on captured photos | Encrypted in transit. Tenant-scoped on every query. Only reachable by that tenant's authenticated users. |
| Internal | Our own operating data | Audit logs, email delivery logs, subscription records, API key metadata | Tenant-scoped where it belongs to a tenant. Admin-only in-app. |
| Public | Meant to be public | Marketing pages, the static template pages under /templates/, API documentation | No restrictions. |
Customer-confidential data is the default assumption for anything a user types or uploads into the product.
3. Access control
3.1 In-product roles
Authorization is a role → capability matrix defined in one file (backend/app/services/permissions.py). Routes ask for a capability, never for a role name. Two properties are deliberate:
- Deny by default. An unknown or missing role resolves to zero capabilities.
Every check is an allowlist, so a role added later gets nothing until it is granted something explicitly. The failure is a 403, not silent access.
- Coarse capabilities. There are fifteen capability boundaries, chosen to
match how a contractor's office actually divides work.
The real matrix:
| Capability | superadmin / owner / admin | manager | designer | reporter | technician |
|---|---|---|---|---|---|
forms.manage | ✅ | ✅ | ✅ | — | — |
forms.fill | ✅ | ✅ | ✅ | — | ✅ |
jobs.manage | ✅ | ✅ | — | — | — |
jobs.field | ✅ | ✅ | — | — | ✅ |
customers.view | ✅ | ✅ | ✅ | ✅ | ✅ |
customers.manage | ✅ | ✅ | — | — | — |
inventory.manage | ✅ | ✅ | ✅ | — | — |
invoices.manage | ✅ | ✅ | — | — | — |
reports.view | ✅ | ✅ | ✅ | ✅ | — |
approvals.manage | ✅ | ✅ | — | — | — |
data.export | ✅ | — | — | — | — |
audit.view | ✅ | — | — | — | — |
team.manage | ✅ | ✅ | — | — | — |
settings.manage | ✅ | — | — | — | — |
billing.manage | ✅ | — | — | — | — |
Notes that matter for an enterprise reviewer:
- Full-account export and audit-log access are account-holder-only. A
manager can run the business but cannot bulk-export it or read the audit trail.
- Ownership is not delegable through an invitation. The roles an admin may
assign are manager, designer, reporter, technician. owner and superadmin are not assignable from the invite flow.
- Self-registration always creates an
adminof a brand-new tenant. The
role in the request body is ignored. An invited technician is always created with role technician, whatever the client sends.
- A second guard (
require_office) protects destructive and money-state
actions. It is also an allowlist — admin, manager, owner, superadmin — so any role added later is denied until it is added deliberately.
3.2 Tenant isolation
Every collection carries the owning company's id, and every query filters on it. Two layers:
1. Read/list scoping. The tenant id is resolved server-side from the authenticated user, never from the request. Sub-users inherit their owning admin's tenant id. 2. Write-time ownership checks. Foreign keys accepted from a client (for example an account_id on a new job) are validated against the caller's tenant before they are stored, in a single shared helper. A reference owned by another tenant returns the same 404 a nonexistent id returns, so the endpoint cannot be used to probe which ids exist elsewhere.
Cross-tenant isolation has dedicated regression tests (section 8).
3.3 Administrative access
- Cloud accounts (Railway, MongoDB Atlas, Cloudflare, Stripe, Resend) are held
by the founder. There are no other human administrators today.
- Policy: MFA must be enabled on every provider admin account (GitHub,
Railway, Atlas, Cloudflare, Stripe, Resend, Anthropic, Intuit). Compliance with this policy is confirmed and recorded at each quarterly access review — see ACCESS_REVIEW.md. This is MFA on our admin accounts; MFA for customers logging in to Field Wizard is a gap (section 12).
- Database network access is currently open to
0.0.0.0/0with credential
authentication, because Railway egress IPs are dynamic. Locking this to a static egress range is on the roadmap (section 12).
- A
superadminplatform role exists for support and can be used to impersonate
a user. Impersonation is stamped into the token (impersonated_by) and carried on the request, and the bootstrap endpoint that grants superadmin is disabled by default (there is a test asserting this).
- Access is reviewed quarterly. See ACCESS_REVIEW.md.
4. Authentication controls
Passwords
- Hashed with bcrypt (per-password salt). Plaintext passwords are never
stored or logged.
- Enforced strength policy on registration and on reset: minimum 8 characters,
at least one uppercase, one lowercase, one digit, and one special character.
- Login does constant-work password hashing even when the email does not exist,
so response timing does not reveal whether an account exists.
Sessions
- JWT access tokens, 30-minute lifetime, plus refresh tokens (7 days) that
exchange for a new pair.
- Tokens carry a
typeclaim and a uniquejti. A refresh token presented to
an access-protected route is rejected by construction, not by luck. There is a regression test for exactly that.
- The application refuses to boot on a weak or placeholder
SECRET_KEY. The
check rejects by shape, not by exact string match, because an operator who copies .env.example and misses one line would otherwise ship a signing key that is public in the repository. Local development must opt in explicitly via an environment variable.
- Accounts suspended or disabled at the platform level are refused at token
validation, not just at login.
Account lifecycle emails
- Password reset tokens: 32-byte random, 1-hour expiry, single use. Using one
invalidates every other reset token for that user. Expired documents are also removed automatically by a database TTL index.
- Email verification tokens: 32-byte random, 24-hour expiry, single use, same
TTL cleanup.
- "Forgot password" always returns the same response and sends mail in the
background, whether or not the account exists — no enumeration oracle from the body or from response latency.
Customer portal (your customers logging in to see their own jobs)
- Portal access codes are **stored as SHA-256 hashes and compared in constant
time** (hmac.compare_digest). The plaintext code is never persisted.
- Passwordless magic links are single-use, expiring, and cleaned up by a TTL
index.
- Portal tokens are a separate token type and are not accepted on staff routes.
Machine access (Public API)
- API keys are high-entropy random tokens (
ff_live_+ 32 random bytes) stored
as SHA-256 hashes. The raw key is shown exactly once, at creation.
- Per-key rate limit of 120 requests/minute, counted in the database so the
limit holds across all worker processes.
- Public API access requires an active Enterprise subscription.
Rate limiting
| Endpoint | Limit |
|---|---|
| Registration | 5 / minute / IP |
| Login | 10 / minute / IP |
| Forgot password | 3 / minute / IP |
| Reset password | 5 / minute / IP |
| Resend verification | 3 / minute / IP |
| Portal login (access code) | 10 / minute / IP |
| Portal magic link request | 5 / minute / IP |
| Public API | 120 / minute / key |
5. Encryption
In transit. HTTPS everywhere. TLS is terminated at Cloudflare in Full (strict) mode, so the hop from Cloudflare to the origin is also encrypted. The API sends Strict-Transport-Security: max-age=31536000; includeSubDomains on every production response.
Other security headers on every response:
| Header | Value |
|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains |
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(self), microphone=() |
X-XSS-Protection | 1; mode=block |
CORS is an explicit allowlist read from configuration — specific origins, specific methods (GET, POST, PUT, DELETE, PATCH), and specific headers (Authorization, Content-Type, X-API-Key). It is not a wildcard.
A Content-Security-Policy header is not set yet. See section 12.
At rest. Database storage is encrypted at rest by MongoDB Atlas (AES-256, provider-managed keys).
Uploaded files go through a storage facade with two interchangeable backends, selected by configuration alone:
- Object storage (S3-compatible; Cloudflare R2 is the provider we have
selected, and the code also runs against AWS S3 or MinIO) — used when a bucket and both credentials are configured. Objects are written private, with server-side encryption where the provider supports it. Rendered PDFs fetch embedded photos and signatures through the same facade, so switching backends cannot silently drop the images out of a report.
- Local disk — the zero-config fallback, on the hosting provider's encrypted
persistent volume.
If object storage is configured, the application verifies at boot that it is actually usable, rather than discovering at the first upload that a dependency is missing and quietly writing customer files onto a container disk that will not survive the next deploy. Object keys are generated server-side and are sanitised so a hostile tenant identifier cannot escape its own prefix.
We do not operate our own key management system, and we do not offer customer-managed encryption keys.
Field-level. Passwords are bcrypt-hashed; portal access codes and API keys are SHA-256 hashed. Beyond those, business data is stored unencrypted at the application layer and protected by the provider's at-rest encryption plus tenant scoping.
6. Secrets management
- No secrets in the repository. Every credential is an environment variable
injected by the hosting platform. .env, .env.local, and .env.production are git-ignored.
- The application refuses to start with a placeholder signing key (section 4).
- Secrets are never written to logs, and the full-account export explicitly
strips a deny-list of credential fields (password hashes, tokens, API key hashes, portal code hashes, Stripe session ids) before anything leaves the building.
- Rotation: on suspected exposure, immediately; otherwise reviewed at each
quarterly access review. Rotating SECRET_KEY invalidates all existing sessions, which is the intended effect during an incident.
7. Change management
Small team, so the process is short and actually followed.
1. Work happens on a branch. Production tracks main; production is never pointed at a work branch. 2. Changes reach main through a pull request. 3. CI runs the full backend test suite on every push and every pull request to main (GitHub Actions, Python 3.11, against a real MongoDB 7 service container). A red suite blocks the merge. 4. Merging to main triggers a Railway deploy of the affected service. 5. The backend exposes /api/health; the platform health-checks it before sending traffic. 6. Rollback is redeploying the previous Railway build.
The founder is the only committer and therefore also the reviewer. That is a real segregation-of-duties limitation and is listed as a gap in section 12. The automated test suite is the compensating control.
8. Testing and vulnerability management
Automated tests. 1,300+ backend tests, run on every push and pull request. Security-specific coverage includes:
- Cross-tenant regression tests — one tenant cannot read, complete, or attach to
another tenant's jobs, templates, submissions, or accounts.
- Role-guard tests — a technician cannot delete an account, mark an invoice
paid, or rewrite an invoice total.
- Token-type confusion — a refresh token is rejected on an access-protected
route.
- Configuration guard tests — the app refuses to boot on a weak
SECRET_KEY;
the superadmin bootstrap endpoint is disabled by default.
- Storage tests — object keys are generated server-side and a hostile tenant id
cannot escape its prefix; downloads stay tenant-checked on both backends.
- Trust page tests — **the build fails if our public Trust Center ever claims a
certification we do not hold, or drops a gap from its published list.** The honesty in these documents is enforced by CI, not by good intentions.
Dependency scanning. Backend dependencies are fully pinned in requirements.txt; frontend dependencies are locked. Scanning runs in CI (.github/workflows/security.yml) on every push to main, every pull request, and on a weekly schedule (Mondays 13:00 UTC) — because dependency vulnerabilities arrive without anyone touching the repository.
| Surface | Tool | Behaviour |
|---|---|---|
| Frontend packages | npm audit --omit=dev | Fails the build at high severity or above for production dependencies. Dev-only advisories are reported without blocking, because they never reach a customer. |
| Backend packages | pip-audit | Audits the pinned requirements with advisory descriptions. |
| Committed secrets | gitleaks | Full-history scan; catches a credential committed by accident before it reaches main. |
| Activity | Cadence |
|---|---|
| Automated scan (all three above) | Every PR, every push to main, weekly |
| Manual dependency review and upgrade pass | Monthly |
| Critical/high advisory affecting a used package | Patched within 7 days of a fix being available |
| Runtime and base image refresh | Quarterly |
Severity handling. Critical: same day. High: 7 days. Medium: next monthly pass. Low: as convenient.
Decisions are recorded, not just actioned. Every advisory that reaches a decision is written up in VULNERABILITY_LOG.md, including the ones we assessed as not exploitable and deliberately did not patch. An advisory against a package we ship is not automatically an exposure — the vulnerable code path has to be one we actually use — and a rushed major-version upgrade has broken more production systems than the average moderate advisory. Writing down the reasoning is what makes "we decided not to" different from "we never looked."
Penetration testing. None has been performed. See section 12. We accept reports from security researchers at security@gofieldwizard.com and will acknowledge within one business day.
9. Logging and monitoring
Application audit log. Security-relevant actions are written to an append-only audit_logs collection with: acting user, tenant, action, resource type, resource id, structured detail, client IP, and UTC timestamp.
What is logged: sign-ins, failed sign-ins, permission and role changes, data exports, portal access grants, API key creation and revocation, file uploads and deletions, and record-level create/update/delete. Action verbs and resource types are declared as constants rather than spelled inline at each call site, so the log is actually filterable — a query for login_failed finds every failed login, not the subset that happened to spell it that way.
Three properties are guaranteed by design:
1. Writing an audit row can never break the action it describes. The logging call is total: it catches everything, including a dead database. An audit sink that is down must not lock every user out of the product. 2. Secrets never reach the collection. Passwords, hashes, tokens, API keys, and connection strings are stripped from the detail payload before insert, whatever the caller passed. Call sites are expected not to pass them; this is the second wall, not the first. 3. Rows expire on a schedule — see below.
Reading it. Tenant-scoped, and the tenant is resolved from the session, never from a query parameter. Gated on the audit.view capability, which is account-holder-only.
Retention: 400 days. That is one year plus roughly five weeks of margin, chosen deliberately for a SOC 2 Type II observation window — the auditor looks back over a full 12 months, and the margin covers the gap between the window closing and fieldwork actually happening. Shorter than a year and the evidence is gone before it is asked for.
Enforcement is belt-and-braces: a MongoDB TTL index on the timestamp (created from the same constant, so the two cannot drift), plus a daily sweeper task started with the application. The TTL index does the real work; the sweeper covers deployments whose index predates a retention change or where the TTL monitor is disabled. See our data-retention schedule.
Infrastructure telemetry.
- Railway: application logs, deploy history, resource metrics.
/api/health: an unauthenticated liveness and readiness probe. It pings
the database and downgrades its status when the ping fails, so a process that is up but cannot reach MongoDB is not reported as healthy. It reports provider names only — never connection strings, keys, or bucket credentials.
- MongoDB Atlas: connection counts, slow queries, cluster alerts.
- Cloudflare: request/DDoS analytics at the edge.
- Sentry: unhandled-exception tracking with a 10% trace sample, enabled when
SENTRY_DSN is configured.
Review cadence. Error and 5xx review daily during the first weeks after any significant deploy, then weekly. Atlas and Cloudflare alerts are delivered to the founder by email and push.
Gap: there is no SIEM, no automated alerting on anomalous authentication patterns, and no log shipping to immutable off-platform storage. Detection today is provider alerts plus human review. See section 12.
10. Backup and recovery
Database. MongoDB Atlas. Backup capability is a function of the cluster tier, and the current production tier's backup coverage is limited. Upgrading the cluster tier specifically to get continuous backups with point-in-time restore is an active, funded item — see section 12. Until it lands, we do not claim a tested restore RPO/RTO, because we would be making it up.
Target once the upgrade lands:
| Measure | Target |
|---|---|
| Backup frequency | Continuous (point-in-time) |
| Backup retention | 7 days point-in-time, plus daily snapshots for 30 days |
| RPO | ≤ 1 hour |
| RTO | ≤ 8 hours |
| Restore test | Quarterly, restoring to a scratch cluster and verifying |
Uploaded files. Since 2026-08-13, job photos, signatures and attached documents are written to a private Cloudflare R2 bucket. Files uploaded before that date are still on the persistent hosting volume until a one-time migration runs; the application resolves each file against the backend that actually holds it, so both serve correctly in the meantime. Neither location has object versioning enabled yet, which means an accidental deletion is not yet recoverable — Section 12, #4.
Code and configuration. Source is on GitHub with full history. Environment configuration is reproducible from GO_LIVE.md. The application is stateless, so rebuilding the compute tier from source is routine; the database and the uploads volume are the only stateful components.
Customer-side recovery. Independent of our backups, any account holder can download their entire dataset at any time as a ZIP of CSV and JSON files. That is a real, deliberate anti-lock-in guarantee and it doubles as a customer-controlled backup.
11. Physical and cloud security (inherited)
Field Wizard operates no physical infrastructure. Data center security, hardware lifecycle, and network-layer protection are inherited from our providers:
- Railway — application compute and the uploads volume.
- MongoDB Atlas — database, running on major cloud provider infrastructure
with at-rest encryption and a SOC 2 Type II report available to customers.
- Cloudflare — DNS, TLS, CDN, DDoS protection at the edge.
Their compliance documentation is linked in our subprocessor list. We inherit those controls; we do not audit them ourselves beyond reviewing published reports.
Endpoint security (policy). The workstation used to administer production must have full-disk encryption on, an automatic screen lock, OS auto-updates enabled, and credentials held in a password manager. Production credentials are not stored in plaintext files on disk. Compliance is confirmed and recorded at each quarterly access review.
Containers. The backend image runs as a non-root user with a defined health check.
12. Known gaps and roadmap
Listed plainly, because a reviewer who finds an undisclosed gap stops trusting everything else in this document.
| # | Gap | Status | Plan |
|---|---|---|---|
| 1 | No multi-factor authentication for Field Wizard user logins. Password + rate limiting is the only barrier at the app. (Our provider admin accounts do have MFA.) | Not started | TOTP enrolment for admin/owner roles first, then optional org-wide enforcement. Highest-priority security feature. |
| 2 | Sessions are not revoked on logout. Logout clears the client. An already-issued access token remains valid until it expires (≤30 min); a refresh token until it expires (≤7 days). | Known | Add a server-side token denylist keyed on jti, plus "sign out everywhere". |
| 3 | Database backups depend on the hosting tier and are not yet at the continuous/point-in-time target. No restore drill has been performed. | In progress | Upgrade the Atlas cluster tier, enable continuous backup, then run and record a restore drill. |
| 4 | Object storage is live; the migration and versioning are not. Since 2026-08-13 new uploads are written to a private Cloudflare R2 bucket. Two gaps remain: files uploaded before that date are still the single copy on the hosting volume, and R2 object versioning is not yet enabled, so a deletion is not recoverable from a prior version. | Partly done — R2 live for new uploads; historical migration and versioning pending | Run scripts/migrate_uploads_to_s3.py --commit on the host that holds the volume, verify an old photo loads and appears in a rendered PDF, enable object versioning on the bucket, then close this row. |
| 5 | No third-party penetration test. No external validation of the application's security posture. | Not started | Commission a scoped web-application pen test once (3) and (4) are done. Findings summary to be made available to enterprise prospects under NDA. |
| 6 | SOC 2 is readiness only. No audit, no report, no auditor engaged. | In progress | Complete the control documentation set (this is it), operate the controls for an observation window, then engage an auditor. |
| 7 | Single-person team. The committer, reviewer, deployer, and incident responder are the same person. No true segregation of duties. | Structural | Compensating controls: mandatory CI on every PR, deny-by-default authorization, tenant regression tests. Revisit at first hire. |
| 8 | No Content-Security-Policy header. Other security headers are set; CSP is not. | Not started | Add a report-only CSP, tune it, then enforce. |
| 9 | Database network access is open to all IPs with credential auth, because platform egress IPs are dynamic. | Known | Move to a static egress IP or a private peering option and restrict the Atlas allowlist. |
| 10 | No centralised log aggregation or security alerting. Detection is provider alerts plus human review. | Not started | Ship application and audit logs to an external store with retention beyond the platform's, and alert on authentication anomalies. |
| 11 | No formal security awareness training programme, because there is one person. | Structural | Introduce at first hire, alongside onboarding/offboarding checklists. |
| 12 | Trial expiry is not enforced server-side. An availability/commercial gap rather than a confidentiality one, but it is a control that does not do what its name implies. | Known | Add the date check in the subscription service. |
| 13 | Dependency scanning has no alerting outside CI. npm audit, pip-audit, and gitleaks run on every PR and weekly (section 8), but a weekly scheduled failure is only seen if someone looks at the Actions tab. | Partly closed — Dependabot now opens grouped update PRs weekly, so a vulnerable version becomes a reviewed PR rather than a red run | Route scheduled-run failures to email/push, the same path as Sentry and Atlas alerts. |
| 14 | Third-party OAuth tokens are stored in plaintext. QuickBooks and similar integration access/refresh tokens live in the integrations documents as-is. Atlas encrypts the disk; a database read exposes them. Our own API keys and webhook secrets are hashed, so this is the one class of credential at rest that is not. | Known | Envelope-encrypt the token fields with a key held only in the environment (Fernet/AES-GCM), decrypt at use, rotate the key on schedule. Never returned by any API today, which is the compensating control. |
| 15 | Browser crash reports went nowhere. The frontend's error boundary posted to /api/client-errors; the route did not exist, so every uncaught render error on a customer's device was a silent 404. | Closed 2026-09-05 — the route exists, rate-limited and bounded, and logs at ERROR where the backend's Sentry hook picks it up | A frontend Sentry SDK with source maps would add stack symbolication; not required for detection. |
| 16 | Trial expiry is not enforced server-side (duplicate of #12, kept separate because it becomes commercial the day a campaign drives sign-ups). require_active_subscription accepts status trialing without reading trial_ends_at. | Known | Decide the post-trial state (read-only vs. locked), then add the date check in the subscription service and a test that a day-31 trial is refused. |
13. Reporting a security issue
Email security@gofieldwizard.com.
- Acknowledgement within 1 business day.
- Initial assessment within 3 business days.
- We will not pursue legal action against researchers who act in good faith,
avoid privacy violations and service degradation, and give us reasonable time to fix an issue before disclosing it.
Incident handling is described in INCIDENT_RESPONSE.md.
Questions about this document
Email Support@gofieldwizard.com and a person will answer. Our security controls and subprocessor list are published at the Trust Center.