A web application to demonstrate an inventory of SSL certificates, with a dashboard that requires authentication and a RESTful API.
- Ruby — version in .ruby-version and Gemfile.
- Rails — Rails ~8.1 with Turbo, Stimulus, importmap-rails (no Node bundler), and Propshaft.
- API — versioned JSON under
namespace :apiin config/routes.rb. - Data — SQLite + Active Record; production uses multiple files under
storage/— config/database.yml. - Serving & deploy — Puma; Docker uses Thruster (Dockerfile); deploy with Kamal.
- Authentication — bcrypt on
User; opaque API tokens onSession. - Tests & CI — Minitest (
bin/rails test); CI: RuboCop, Brakeman, Bundler Audit, importmap audit — config/ci.rb.
Browser clients use cookie-backed sessions (Authentication, SessionsController, DashboardController). API clients use Bearer tokens on Session rows via Api::V1::BaseController. Certificates always belong to a User; HTML sign-in may call SampleCertificates once per empty account. Production persists data in SQLite files under storage/ (primary, cache, queue, and cable DBs per config/database.yml).
flowchart TB
subgraph clients [Clients]
browser[Browser]
apiClients[JSON API clients]
end
subgraph htmlStack [HTML stack]
sessionsCtrl[SessionsController]
dashCtrl[DashboardController]
authCookie[Authentication and cookie session]
end
subgraph apiStack [API v1]
apiSessionsCtrl[Api::V1::SessionsController]
apiCertsCtrl[Api::V1::CertificatesController]
end
subgraph domain [Domain]
userModel[User]
sessionModel[Session]
certModel[Certificate]
sampleSvc[SampleCertificates]
end
subgraph persistence [Persistence]
sqlite[SQLite in storage]
end
browser --> sessionsCtrl
browser --> dashCtrl
browser --> authCookie
authCookie --> sessionModel
sessionsCtrl --> userModel
sessionsCtrl --> sessionModel
sessionsCtrl --> sampleSvc
sampleSvc --> certModel
dashCtrl --> certModel
apiClients --> apiSessionsCtrl
apiClients --> apiCertsCtrl
apiSessionsCtrl --> sessionModel
apiCertsCtrl --> certModel
userModel --> sessionModel
userModel --> certModel
sessionModel --> sqlite
certModel --> sqlite
The status chart groups certificates into four buckets:
| Label | Meaning |
|---|---|
| Revoked | revoked_at is set. These are counted only here, even if the certificate is still within its not-yet-expired dates. |
| Expired | Not revoked and not_after is in the past. |
| Lapsing | Not revoked, still within its validity window by dates, but not_after is within the next 30 days (inclusive of the boundary). Same logic as the Certificate.expiring_soon scope (default 30-day window). |
| Active | Not revoked and not_after is more than 30 days after the current time. |
So Lapsing is shorthand on the chart for “expiring soon”—certificates that are not yet calendar-expired but should be renewed or replaced soon.
The body of the page is a chart of how many certificates you have in each status bucket—Revoked, Expired, Lapsing, and Active. Each slice’s size is the number of certs in that bucket.
The v1 surface follows resource-oriented URLs and maps HTTP verbs to CRUD-style operations, with JSON request and response bodies and conventional HTTP status codes. Routes live under the versioned prefix /api/v1 with format: :json as the default (config/routes.rb).
Api::V1 controllers inherit from ActionController::API with JSON as the default format. Jbuilder is in the Gemfile if you add .json.jbuilder views; certificate endpoints today build response bodies as hashes in Api::V1::CertificatesController.
Each request is self-describing: authentication is sent as an Authorization: Bearer header (no server-side coupling to the previous response except the token value you store). That matches common RESTful JSON API practice even though it is not “hypermedia” (HATEOAS) and there is no machine-generated OpenAPI document in this repository—reasonable tradeoffs for a small API.
Singleton session resource — resource :session, only: %i[create destroy] treats “the current session” as one logical resource per client:
| HTTP verb | Path | Meaning |
|---|---|---|
POST |
/api/v1/session |
Create a new Session (authenticate with email/password); 201 Created with a token on success. |
DELETE |
/api/v1/session |
End the session identified by Authorization: Bearer …; 204 No Content on success. |
Certificate collection — resources :certificates exposes certificates as a collection scoped to the authenticated user:
| HTTP verb | Path | Meaning |
|---|---|---|
GET |
/api/v1/certificates |
List certificates (index). Optional query ?status=active, expired, or revoked filters the collection. |
POST |
/api/v1/certificates |
Create a certificate (201 Created with body on success). |
GET |
/api/v1/certificates/:id |
Read one certificate (200 OK). |
PATCH / PUT |
/api/v1/certificates/:id |
Update fields (200 OK with body, or 422 on validation errors). |
DELETE |
/api/v1/certificates/:id |
Delete a certificate (204 No Content). |
- Ruby: see .ruby-version and the
rubyline in the Gemfile. - Setup:
bin/setup(orbin/rails db:prepareandbin/rails db:seedas needed). - Run:
bin/rails server(orbin/dev), then open the app URL (e.g.http://localhost:3000). - Tests:
bin/rails test
- Obtain a token with
POST /api/v1/session(JSON body withemail_addressandpassword). Revoke it withDELETE /api/v1/sessionand the sameAuthorizationheader. - Tokens expire after 30 days (
Session#expires_at); expired tokens are rejected and removed. - Certificate bodies nest attributes under a
certificateroot key. SeeApi::V1::CertificatesControllerfor permitted fields.
Certificates are scoped per user. After a successful browser sign-in, if that user has no certificate rows yet, the app inserts 10 sample certificates for that user only. Signing out deletes that user’s certificates (other users are unaffected). The JSON API does not insert or clear demo data on POST /api/v1/session.
- Development: creates
admin@example.comwith password fromADMIN_PASSWORD, defaulting topasswordwhen the variable is unset. - Production: seeds create no default admin. Set
ADMIN_EMAILandADMIN_PASSWORDto bootstrap one account.
- Tenant boundary: Every
Certificatebelongs to aUser(user_idis required). - API:
Api::V1::CertificatesControlleronly loads and mutatescurrent_user.certificates. Another user’s id returns 404, not another tenant’s row. - Dashboard: The inventory chart uses
current_user.certificatesonly (DashboardController). - Sign-out: Browser sign-out deletes only the signed-in user’s certificates, then destroys the
Sessionrow (SessionsController,Authentication). - Demo data: Sample rows are created only on HTML sign-in when that user has no certificates yet; the JSON API does not insert or wipe certificate data on
POST /api/v1/session.
- Browser cookie:
session_idis signed, HttpOnly, SameSite=Lax, andSecurein production (Authentication#start_new_session_for). Expired sessions are dropped when the cookie is presented (Authentication#find_session_by_cookie). - Absolute expiry: API and HTML sessions share the same
sessionstable withexpires_at(default 30 days from creation). Expired Bearer tokens are rejected and the session row is removed (Api::V1::BaseController). - Revocation: API clients can end a token with
DELETE /api/v1/sessionusing the sameAuthorization: Bearerheader.
- Credential stuffing:
POSTto create a session is rate-limited to 10 requests per 3 minutes per the Rails rate limiting API for both HTML andApi::V1::SessionsController. Password resetcreateuses the same limit (PasswordsController).
- TLS: Production sets
assume_ssl,force_ssl, and HSTS via Rails;GET /upis excluded from the HTTP→HTTPS redirect. - Host header: When
RAILS_ALLOWED_HOSTSis set,config/environments/production.rbenables explicitconfig.hostsand excludes/upfrom host authorization checks. - CSP: A Content-Security-Policy is applied with nonces for
script-src. There is no broad CORS configuration; the HTML app is same-origin and the API is expected to be used server-to-server or from trusted clients you configure yourself.
- Logs: Request parameter filtering includes passwords, tokens, and related keys (
filter_parameter_logging.rb). - Bootstrap users: Production does not create a default admin in seeds unless
ADMIN_EMAILandADMIN_PASSWORDare set.
config/ci.rb runs RuboCop, Bundler Audit, Importmap audit, Brakeman (warnings fail the step), and the full test suite including db:seed:replant in the test environment. Brakeman currently runs with --except EOLRuby until the Ruby runtime is upgraded past the scanner’s end-of-life warning for the version in .ruby-version.
Residual risks (compromised host, stolen laptops, zero-days) still require process and infrastructure controls beyond this application.