Releases: ibuilder/scopemaker
Release list
v1.5.1 - SSO account linking requires a verified email
A security fix for single sign-on. If you run ScopeMaker with
OIDC_ENABLED=1, upgrade.
An unverified email claim could take over an existing account
provision_sso_user matched an incoming OIDC identity on the issuer's subject
first and the email claim second. The email fallback would bind that identity to
any pre-existing local account with the same address — including one that
had a password, an admin role, and a project's worth of documents.
That trusts the identity provider to have verified the address, and not all do.
A multi-tenant IdP with self-service signup will happily issue a token asserting
somebody else's email. Whoever held such a token inherited the victim's account,
their organizations and their documents, without ever knowing the password.
The fix. Linking to an account that already exists now requires the provider
to report email_verified, and an absent claim counts as unverified — an issuer
that says nothing has confirmed nothing.
Two things deliberately unchanged:
- Subject matching. A subject is issued by the provider and cannot be chosen
by the person signing in, so it was never the risk. Someone whose email
changes at the IdP still keeps their account. - Creating a new account from an unverified address. It lands in its own
organization and can reach nothing that already exists.
OIDC_REQUIRE_VERIFIED_EMAIL=0 restores the previous behaviour for an identity
provider you operate that omits the claim entirely.
Who is affected: deployments with OIDC_ENABLED=1 whose identity provider
can issue tokens for addresses it has not verified. Password-only and
Procore-only deployments are unaffected.
How it was found
The function had no direct test coverage. Writing that coverage surfaced the
bug: the test demonstrating the takeover was written first and failed against
the old code.
A real identity provider is only needed for the OAuth handshake — the matching
logic takes a plain claims dict, so it was testable all along. There are now 20
tests covering subject matching, email changes at the provider, provider
scoping, the domain allowlist and organization attachment. Coverage of
accounts.py went from 47% to 86%.
One of those tests pins down a decision that looks like a bug and is not: with
no OIDC_DEFAULT_ORG configured, two SSO users sharing an email domain each get
their own organization rather than being grouped. Grouping people who merely
share a domain would put strangers in one tenant the first time somebody signed
in with a consumer address.
Also
The project page now describes what
the product actually does — it had drifted four releases behind, and never
mentioned the coverage analysis that finds the scope gaps its own opening
argument is about.
Upgrading
No migration. No configuration change required; OIDC_REQUIRE_VERIFIED_EMAIL
defaults to on.
Full changelog: https://github.com/ibuilder/scopemaker/blob/main/CHANGELOG.md
v1.5.0 - Data rights, keyboard access, verified backups
Everything in this release came from looking at the product rather than reading
the code: rendering an exhibit and inspecting it, measuring against a real
database, and driving the editor from a keyboard. Each of those turned up a
defect that the 380-test suite was structurally unable to see.
The outline is reorderable without a mouse
The editor had 72 items with draggable="true", none focusable, and no
keyboard alternative. Reordering clauses — the core editing action of the
application — was impossible without a pointer. That is a WCAG 2.1.1 failure,
and no test would ever have caught it.
Every item now has move up/down buttons driving the same persistence path the
drag handler uses. Focus is restored to the moved button deliberately: moving a
node in the DOM blurs it, which would drop the user at the top of the document
after every press. The labels name the section as well as the item, because
item numbers restart in each section and "Move item 1. up" otherwise appeared
several times on one page.
Also fixed: the admin role dropdown had no accessible name, so every row
announced identically, and no table declared scope on its header cells.
Export your data, delete your account
An export is personal data, not the employer's documents. It lists the scopes
you authored — id, title, status, when — without their contents, because those
belong to the organization that paid for them. Tests assert that no password
hash, token hash, raw token or MFA secret appears anywhere in the output.
Deletion is bounded by what has to survive it:
- The audit log keeps
actor_labelwhen the foreign key nulls out, so deleting
an account cannot erase what it did. - Shared organizations keep their scopes; the documents lose only the
authorship link. - The last administrator of an organization with other members is blocked,
not warned — leaving would strand those members. - An organization whose only member leaves is deleted with them, because nobody
could ever sign in to it again. The confirmation page names it, since that is
the destructive part nobody anticipates.
Backups are rehearsed, not assumed
docs/deployment.md told operators to back up PostgreSQL and said that was all
the state there was. Nobody had ever restored one.
CI now populates a database, pg_dumps it, drops the schema, restores, and
verifies. Row counts alone would pass even with the encrypted columns coming
back as mush, so it also re-renders a reference exhibit and compares it byte for
byte. Latest run: 642 rows across 19 tables, restored and rendering identically.
Two defects found by measuring
A clause with a long sub-list jumped the page whole. Page 1 of the Division
21 exhibit was 45% blank — clause 3 has 22 specification sections under it, and
the wrapper <li> holding that sub-list inherited break-inside: avoid, making
the entire block unbreakable. The existing PDF tests asserted that text was
present and that the document paginated; both passed. There are now assertions
on how far down each page the content reaches.
The scopes list issued a query per row — 32 at 25 scopes, 16 at 12. Each row
lazily loaded its bid package. Projects repeat and were answered from the
identity map, which is exactly why a single-project dataset looked fine. Now a
constant 7, verified at 10 and 30 scopes.
The load test also runs against PostgreSQL with 8 concurrent clients on every
push now, published to the job summary. SQLite serialises writers, so the local
numbers had been measuring lock contention rather than the application.
Upgrading
No migration. No configuration change.
Full changelog: https://github.com/ibuilder/scopemaker/blob/main/CHANGELOG.md
v1.4.1 - API token verification cost
A performance fix on the API's authentication path, and test coverage for a
security control that had none.
API token verification: 151 ms → 7.6 ms
Argon2 is deliberately slow. That is correct for a password typed once and wrong
for a credential presented on every call — verifying the bearer token was most
of a ~150 ms API request.
A short-lived per-process cache now skips the hash comparison for a token that
has already verified. Measured back to back in one process on /api/v1/me:
| Median | |
|---|---|
| Argon2 every request (previous) | 150.8 ms |
| With the verified-token cache | 7.6 ms |
What is cached is the verification, never the authorization decision. Every
request still loads the row and re-checks revocation and expiry, so revoking a
token takes effect on the very next call. There are tests for revocation,
expiry and deletion specifically, because that is the property a later refactor
would quietly break.
The raw token is never a cache key — it is hashed with BLAKE2b keyed on
SECRET_KEY, so a memory dump yields nothing usable. The cache is bounded and
entries expire after five minutes.
last_used_at now writes at a five-minute resolution rather than on every
request. It answers "roughly when was this token last seen", which does not
justify a database write per API call.
Rate limiting is now actually tested
The entire suite ran with RATELIMIT_ENABLED = False — rate limits and fixtures
that sign in dozens of times do not mix — which left the only thing standing
between a password guesser and unlimited attempts with no coverage at all. An
upgrade could have turned it into a no-op and nothing would have failed.
tests/test_rate_limiting.py builds its own application with limiting on and
asserts the eleventh login POST inside a minute is refused, that a correct
password does not bypass an exhausted limit, and that GET is untouched.
Dependencies
actions/checkout v4→v7, actions/setup-python v5→v7,
actions/configure-pages v5→v6, actions/deploy-pages v4→v5,
docker/setup-buildx-action v3→v4, and Flask-Limiter widened to allow 4.x —
safe to take now that the limiter has real coverage. Verified against both 3.12
and 4.1.1.
Upgrading
No migration. No configuration change.
Full changelog: https://github.com/ibuilder/scopemaker/blob/main/CHANGELOG.md
v1.4.0 - Rendering off the request path
Track C: the work that decides whether ScopeMaker holds up under more than one
user at a time. Every change here was measured before and after.
Rendering leaves the request path
Render cache. Every export is keyed on a fingerprint of the document's
actual content — scope fields, project, bid package, every section and item.
An unchanged scope is served from stored bytes and renders nothing.
The obvious implementation, hashing updated_at, is wrong here. The edit routes
set updated_by_id to mark a scope dirty, and assigning the same user id is
not a change — so SQLAlchemy issues no UPDATE and onupdate never fires. One
person editing two items in a row would have kept the old fingerprint and been
served a document missing their edit.
Render queue. With RENDER_ASYNC=1 a render that is needed goes to a
worker (flask run-worker) and the request returns a small polling page instead
of holding a gunicorn worker for a second or two.
The queue is a database table, not Redis. This deployment already runs
PostgreSQL, and mandating a broker in order to download a PDF is a poor trade.
Workers claim jobs with a conditional UPDATE rather than FOR UPDATE SKIP LOCKED, so the same code runs on SQLite and PostgreSQL and several workers can
share one queue:
docker compose up -d --scale worker=3A worker that dies mid-render leaves its job running; the next one requeues it
after ten minutes and gives up after three attempts. Async is off by default,
because turning it on without a worker means exports never complete.
The editor's N+1
Rendering a scope walked item.children per item — one query each. The tree is
now built from the already-loaded flat collection with set_committed_value,
which also stops SQLAlchemy re-fetching it.
Measured on 13 scopes / 843 items:
| Page | Before | After |
|---|---|---|
| editor | 167 ms, 73 queries | 110 ms, 11 queries |
| DOCX export (repeat) | 341 ms, 20 queries | 13 ms, 7 queries |
Optimistic locking
Scopes carry a row_version. Two people editing the same document now get an
explicit conflict rather than one silently overwriting the other.
Metrics
/metrics serves Prometheus text exposition — request counts and latency,
render timings by format, export cache hit rate, live queue depth. No
prometheus_client dependency; the text format is simple enough to emit
directly and most self-hosters will never scrape it.
The endpoint returns 404 until METRICS_TOKEN is set, then requires the
token. Requests are labelled by Flask endpoint, never by path: paths contain
scope ids, and one time series per document is how a Prometheus instance falls
over.
scripts/load_test.py builds a throwaway dataset and reports latency
percentiles alongside the query count per page — the number that actually
transfers from a laptop to production.
Fixed
- Deleting a parent item promoted its children to the top level instead of
deleting them, quietly corrupting the outline. render_nownever stampedstarted_aton the synchronous path, so those jobs
reported no duration.
Upgrading
docker compose pull && docker compose up -dOne migration (a0e2627d3a55) adds the render_jobs table and
scopes.row_version. Nothing else changes; async rendering and metrics are both
opt-in.
Full changelog: https://github.com/ibuilder/scopemaker/blob/main/CHANGELOG.md
v1.3.0 — Two-factor, audit log and security policy
Track B: what it takes to survive a customer's security questionnaire.
Important
This repository is now ibuilder/scopemaker. GitHub redirects the old URLs. Documentation has moved to https://ibuilder.github.io/scopemaker/. The product is ScopeMaker — the scope engine is the point, and the Procore connector is one optional integration among others, documented in docs/integrations.md. The integration itself is unchanged and still fully supported.
Two-factor authentication
TOTP with single-use recovery codes. The enrolment QR is rendered as an inline SVG rather than fetched from a chart API — a surprising number of tutorials hand the shared secret to a third party, and it would also break the strict default-src 'self' policy.
The property the tests lean on hardest: a correct password alone does not authenticate anyone who has a second factor. Login parks a pending challenge in the session instead of signing you in. That marker expires, carries the session epoch so a password change mid-challenge invalidates it, and its failures count against the same account lockout — so the second factor cannot be brute-forced independently of the first.
Turning MFA off requires re-entering your password, because that is exactly what a hijacked session would try first.
Organization security policy
Admin → Security lets you require two-factor, or require single sign-on.
Enforced on every request, not just at sign-in. A policy that only applies to the next login leaves every currently-open session untouched — which is precisely the window an administrator turns it on to close. The enrolment pages are exempt from the redirect, or a user has nowhere to land.
sso_only refuses a correct password outright, and cannot be enabled when no identity provider is configured — that would lock everyone out.
Audit log
Append-only record of sign-ins and failures, lockouts, password resets, session revocation, role changes, member removal, invitations, token issue and revocation, scope issue/revise/archive, MFA changes, and integration activity.
Entries outlive their actor: the foreign key is nulled when a member is deleted, but their email is preserved on the row — removing somebody does not erase what they did. Filterable, with a security-events-only view and CSV export.
Supply chain and code quality
SECURITY.mdwith a private disclosure route, the full security posture, an honest section on what this does not protect against, and a hardening checklist- Dependabot for pip, GitHub Actions and Docker
- CI now runs
pip-audit --strictand publishes a CycloneDX SBOM artifact - mypy blocks the build. It was
continue-on-error, which meant nobody read it. The seven type errors it was hiding are fixed — including twoScopeSection | Nonedereferences that would have been 500s.
Fixed
- API tokens bypassed the organization's MFA requirement. The request hook that enforces policy keys off Flask-Login, and a bearer token is not a session — so a token issued before the policy was enabled kept working indefinitely. Enforcement now also runs where the bearer identity resolves.
- Alembic renders JSONB columns as
JSONB(astext_type=Text())without importingText, aNameErrorthe moment the migration runs. Fixed in the affected migrations and inscript.py.makoso it cannot recur.
330 tests, green on Python 3.11 and 3.12 with the PDF stack installed. ruff and mypy clean, pip-audit reports no known vulnerabilities.
v1.2.0 — Account security
Track A of production readiness: make the app safe to put real users on.
The headline gap was blunt — there was no password reset. A user who forgot their password was locked out permanently unless somebody with shell access ran a CLI command. That's fixed, along with the rest of the account-security floor.
Added
Password reset. Single-use, expiring tokens stored as Argon2 hashes. Requesting a new link invalidates the previous one, and completing a reset signs out every existing session — so a reset genuinely evicts an attacker rather than running alongside them.
Email, built on smtplib rather than an extension. Three backends: console (the development default — the message and its link go to the log, so a reset works with no mail infrastructure at all), smtp, and null for tests. Delivery failure is logged, never raised into the user's request. Invitations are now emailed rather than only surfacing a link.
Account lockout, counted per account with a growing backoff. Per-account rather than per-IP, because an IP limit does nothing against credential stuffing spread across addresses. A locked account fails before the password is checked and returns the identical message to every other failure — so the lockout can't be probed, and the endpoint still can't be used to enumerate addresses.
Session revocation. The session cookie carries a per-user epoch; bumping it invalidates every live session at once with no server-side session store. Surfaced as "sign out everywhere else" on the profile page, and triggered automatically by password changes and resets.
A loud startup warning when rate limiting uses in-memory storage in a multi-worker deployment, where configured limits are silently multiplied by the worker count. Redis is now wired into docker-compose.yml.
Fixed
Two bugs found by actually exercising this rather than reading it:
login_user(current_user)caused aRecursionError. Flask-Login stores whatever it's handed ong._login_user, andcurrent_useris a LocalProxy that reads that same slot — so passing the proxy made it resolve to itself. Both call sites now pass the concrete object.- Two forms on the profile page each had a field named
submit, so posting either one looked like a submission of both.
Changed
Production configuration now refuses to boot without a mail relay, for the same reason it already refuses to boot without a secret key: a deployment that cannot send a password reset is not a working deployment.
Upgrading
The migration adds NOT NULL columns to a populated users table, so it carries server defaults and drops them again afterwards — safe on a live database. Everyone is signed out once on upgrade: existing cookies have no session epoch and are rejected, which is the correct behaviour for the release that introduces session revocation.
Set MAIL_SERVER before deploying, or production will refuse to start.
267 tests, green on Python 3.11 and 3.12 with the PDF stack installed.
v1.1.0 — Scope coverage analysis
Finds the work that ended up in nobody's contract.
📖 Documentation · API · Full changelog
Scope coverage analysis
A scope gap is work that appears in the drawings but ends up in no trade's contract — classically at the seam between two trades, where each assumed the other had it. It surfaces during construction, and somebody pays for it then.
Every other tool in this space has to infer coverage from PDFs. ScopeMaker already holds each exhibit as structured rows, so the same question is just a query.
/projects/{id}/coverage lines up the specification sections claimed across every scope on a project and reports four things:
| What it means | |
|---|---|
| 🔴 Gap | Applies to a division on this project, claimed by nobody |
| 🟠 Overlap | A trade-specific section claimed by two trades — probably bought twice |
| 🔵 Shared seam | Cross-referenced by design and carried by several trades — correct, but the split still needs deciding |
| 🟡 Unassigned hand-off | An exclusion that pushes work to a division with no scope on the project |
The shared-seam distinction is the part that makes it usable. Four trades claiming 078413 Penetration Firestopping is not a double-buy — every trade firestops its own penetrations. Reporting that as an error would train people to ignore the whole page. But it isn't nothing either: who paints the exposed sprinkler pipe and who furnishes the access door for whose valve are exactly the questions that become change orders, so they get their own section.
Likewise, fire protection excluding the fire alarm is standard and correct — right up until you notice there's no Division 28 package on the job. Then it's a gap in the making, and it gets flagged.
Also lists bid packages with no scope written yet. Available as CSV for buyout meetings and at GET /api/v1/projects/{id}/coverage.
A hand-edited spec line still counts as claimed — the analysis prefers the structured id recorded at generation and falls back to a six-digit number in the text, so rewording a line cannot manufacture a phantom gap.
Fixes
.envwas never actually being read. Config classes reados.environat class-definition time, soload_dotenv()in the application factory ran too late to matter. TheflaskCLI masked this by loading dotenv itself; gunicorn or any script silently fell back to the default SQLite path and presented as a mysteriously empty database.- The licence file didn't match the declared licence —
LICENSEwas GPL-3.0 while everything else said MIT. It's now the MIT text.
Tooling
gh workflow run sample.yml renders a full set of exhibits as PDF, DOCX, Markdown and JSON and uploads them as an artifact — a way to review real output without installing the WeasyPrint native stack locally.
239 tests, green on Python 3.11 and 3.12 with the PDF stack installed.
v1.0.0 — ScopeMaker
Construction scope of work exhibits, generated properly.
📖 Documentation · Deployment · API · Clause library
This is a complete rewrite
The repository was previously a browser-only prototype that could not run: every page loaded js/procore-api.js, js/exhibit-generator.js, js/database-service.js and js/export-utils.js, and none of those files were ever committed. v1.0.0 replaces it with a Flask 3 application built around the part that actually matters — generating the scope, not the Procore connector.
| Prototype | v1.0.0 | |
|---|---|---|
| Does it run? | ❌ Four referenced JS files never committed | ✅ Flask 3, 9 blueprints, service layer |
| OAuth secret | ❌ Browser localStorage |
✅ Server-side only, Fernet-encrypted at rest |
| PDF export | ❌ html2canvas screenshot on one A4 page |
✅ WeasyPrint paged media, selectable text, Page N of M |
| DOCX export | ❌ alert('would be implemented here') |
✅ python-docx, formatting runs, live page fields |
| Scope content | ❌ One hardcoded Fire Protection sample | ✅ 236 clauses, 139 spec sections, 20+ trades |
| CSI divisions | ❌ 16 hand-typed, several nonexistent | ✅ Canonical MasterFormat 2020, reserved excluded |
| Storage | ❌ Browser localStorage |
✅ PostgreSQL, SQLAlchemy 2.0, Alembic |
| Accounts | ❌ None | ✅ Organizations, roles, invitations, OIDC SSO |
| Tests | ❌ None | ✅ 216 tests, CI on Python 3.11 and 3.12 |
| Third-party JS | ❌ 8 CDN <script> tags |
✅ Zero — strict default-src 'self' CSP |
What makes it useful
Cross-division specification references. A Division 21 fire protection package is automatically offered the Division 07 firestopping, Division 08 access doors and Division 28 fire alarm interface it is contractually responsible for. Missing those is where scope gaps come from.
Numbering that means something. Outline labels are computed once and written literally into every format, so clause 3.2.4 identifies the same sentence in the PDF, the Word file, the Markdown and the JSON.
Documents that survive review. Real paged PDFs with running headers, footers and page numbers, and text that is selectable and searchable. Word files you can redline.
Versioned and auditable. Issuing a scope freezes an immutable revision. Later edits create a new version, so what went out with the subcontract is preserved verbatim.
Getting started
git clone https://github.com/ibuilder/procore-exhibit-generator.git
cd procore-exhibit-generator
cp .env.example .env
python -c "import secrets; print('SECRET_KEY=' + secrets.token_urlsafe(64))"
python -c "from cryptography.fernet import Fernet; print('ENCRYPTION_KEY=' + Fernet.generate_key().decode())"
docker compose up --build
docker compose exec web flask create-user you@example.com --org acme --role admin
docker compose exec web flask demo-data --org acmeImportant
Outside Docker, PDF export needs WeasyPrint's native libraries (Pango, cairo, GDK-PixBuf). Without them the app still runs and DOCX, HTML, Markdown and JSON all export — only PDF is disabled, and the UI says so. Run flask check-pdf to see where you stand; the deployment guide has the per-platform commands.
Security fixes
- Removed the Procore client secret from browser
localStorage, where any XSS could read it - Migrated off traditional Procore service accounts (retired 2025-03-18) to Developer Managed Service Accounts
- Argon2id password hashing, hash-only API token storage,
bleachallowlist sanitization ALLOWED_HOSTSvalidation, open-redirect checks, boundedProxyFixtrust- Tenant isolation enforced in one place; cross-tenant access returns 404, not 403
- Production config refuses to boot without secrets, and refuses SQLite
Breaking changes
Everything. There is no upgrade path from the prototype — it had no persistent storage to migrate. The static HTML files have been removed and the product is renamed ScopeMaker.
Full detail in CHANGELOG.md.
Not affiliated with or endorsed by Procore Technologies, Inc. CSI MasterFormat division titles are used for identification; the complete section list is published and copyrighted by the Construction Specifications Institute.