Skip to content
HEROSAPPS
Home
HRMS Hero
Reporter Hero Coming soon
Planner Hero Coming soon
About Us Contact
Home HRMS Hero About Us Contact

Legal

Security

HRMS Hero Security Model

Select app:
  • HRMS Hero Available
  • Reporter Hero Coming soon
  • Planner Hero Coming soon

Last updated: 29 July 2026

This document is the source of truth for scopes, data flows, identity, and the security gates of the HRMS Hero app. It is governed by the HRMS Hero Constitution and kept current with every change.

Phase 1 (Approvals + Leave) addendum is in section 8 below. Phase 1 adds the configurable approval engine, leave lifecycle, panels/queue, notifications, accrual, and SLA sweep on top of the Phase 0 foundation.

1. Requested scopes and justification (least privilege)

Scope Why it is required
read:jira-work Read project/issue/field configuration to verify and operate setup.
write:jira-work Create/configure HR projects, issue types, fields, and screens.
read:jira-user Resolve the caller's identity and group membership for role gating.
manage:jira-configuration Create permission schemes, issue-security schemes, and custom fields during setup.
storage:app Access app-private Forge Storage and Forge SQL.

No other scopes are requested. permissions.external.fetch is empty — the app makes no outbound non-Atlassian calls in Phase 0. Adding any scope or egress entry requires a reviewed PR updating this table.

2. Identity and authentication

  • The app implements no authentication: no login screen, no password handling, no credential store.
  • The current user is taken only from the Forge invocation context (context.accountId).
  • Site sign-in is delegated to the organisation's existing SSO (Microsoft Entra ID / Azure AD via Atlassian Access/Guard). MFA, conditional access, and de-provisioning are inherited — a user disabled in the IdP loses Jira/app access automatically.
  • Group membership may be SCIM-provisioned or managed manually; the app's role logic works either way.

3. Data protection

  • Operational data lives in app-private, encrypted Forge Storage / Forge SQL — not readable by Jira end users.
  • No regulated PII (salary, bank details, medical notes, confidential reasons) is stored in any operational record, log, or audit detail. Where such data is needed later it is access-controlled separately.
  • Sensitive HR requests are protected by Jira issue-security levels; the restricted HR Admin project is limited to HR/Finance/admins.

4. Authorization

  • All admin/reporting resolvers are server-side permission-gated via src/lib/permissions.ts (requireRole); client-side hiding is never relied upon.
  • asUser() is used where an action must respect the caller's Jira permissions; asApp() only for justified system operations.

5. Audit

  • Every state-changing reference-data action writes an immutable row to audit_event (who/what/when, non-PII summary). No update/delete path exists.

6. Security and quality gates

CI blocks merge on failure of: lint, typecheck, unit tests, build, SAST (eslint-plugin-security), SCA (production dependency vulnerabilities — fails on High/Critical), secret scanning (gitleaks), and license compliance (denylist of strong-copyleft/non-commercial licenses). All actions are pinned to immutable commit SHAs and checkouts disable credential persistence (supply-chain hardening).

DAST status — planned, not yet enforced. The DAST job is currently a manual-dispatch placeholder with no scanner configured; it is not a merge-blocking gate yet. A real DAST stage is a follow-up before production launch.

Scanner products of record: eslint-plugin-security (SAST), npm audit scoped to production deps (SCA), gitleaks (secret scanning), license-checker with a copyleft/non-commercial denylist (license compliance).

Known accepted advisories: 6 moderate uuid-related advisories reach the app transitively through @forge/react / @atlaskit/* (first-party Atlassian packages we do not control). They are below the High/Critical merge-blocking threshold and are tracked for upgrade when Atlassian ships fixed versions.

7. Vulnerability management

  • Dependencies pinned to exact versions; bumps go through a reviewed PR passing the SCA gate.
  • Remediation SLAs: Critical — before next release; High — within the defined window. A coordinated disclosure / security contact is published with the Marketplace listing.

8. Phase 1 — Approvals engine and leave

8.1 Scope review

Phase 1 adds no new scopes: workflow modules, issue panels, scheduled triggers, the product trigger, and the queue consumer all operate within the Phase 0 scope set. Routing sets the issue assignee using the existing write:jira-work. Notifications are sent via the in-product notification capability covered by existing scopes. permissions.external.fetch stays empty (no egress).

8.2 Decision authority — defense in depth

A decision is authorized twice: the hrms-wf-condition workflow condition gates the transition in-product (good UX), and the post-function/approvals.decide resolver re-checks authority server-side before any state/balance change. Both use the same pure approvalChain.isAuthorized + approverResolver logic — a non-approver is denied even if they bypass the UI. The approval queue (approvals.resolveQueue) is permission-scoped: it returns only items the caller may act on.

8.3 Idempotency and immutability

Every balance mutation is guarded by a set-once processed-marker (processed:{issueKey}:{transition}:{level}), so duplicate workflow/event delivery never double-counts. The approval trail is append-only and concurrency-hardened (per-key lock + optimistic rev). Each decision/balance change writes an immutable approval_event / audit_event row.

8.4 Privacy

Reasons are treated as non-regulated free text and are NOT written to facts/audit/notifications — fact rows hold counts, dates, identity references, and a comment-presence concept only; reason content (if any) stays on the Jira issue under issue-security. Per-type permitsNegativeBalance (e.g. unpaid leave) lets a balance go negative as a tracked liability without weakening any control.

8.5 Threat notes

  • Privilege escalation on approve — mitigated by server-side authority re-check; covered by negative tests.
  • Client-tampered submit (over-balance/overlap) — re-validated server-side in submitRequest/validator; covered by tests.
  • Duplicate event double-spend — processed-markers; covered by idempotency tests.
  • Lost trail entry under concurrency — per-key lock; covered by concurrency tests.

9. Phase 2 — Timesheets

9.1 Scope review

Phase 2 adds no new scopes. Worklog read and write (including reading/writing the hrms-category worklog property and creating Timesheet Period / Exception issues) are covered by the existing read:jira-work / write:jira-work. The new worklog product trigger, the two scheduled triggers (weekly generation, daily sweep), and the issue panel introduce no new auth surface. permissions.external.fetch stays empty — no egress is introduced.

9.2 Worklog product-trigger surface

The hrms-worklog-trigger fires on worklog created/updated/deleted and invokes worklogReconcile server-side. It is an internal Forge event handler — not a user-callable resolver — and performs no privileged action beyond reconciling totals (recompute-from-source) and, for locked periods, raising an Exception + HR notification. It carries no untrusted client input that bypasses authorization, and writes only operational hours/counts and identity references (no regulated PII).

9.3 asUser worklog writes (attribution integrity)

Worklogs created or edited through the weekly grid are written asUser so the worklog's author identity is genuinely the acting employee — never the app. Author identity governs period attribution, so attribution cannot be spoofed by routing writes through the app. Reads and writes on every grid/panel/queue path are permission-scoped server-side; client-side hiding is never relied upon.

9.4 Lock-integrity — a detective control (by design)

Native worklog edits bypass app workflow validators, so a worklog change inside a Locked period cannot be hard-blocked. Lock integrity is therefore enforced detectively, not preventively:

  • A worklog created/edited inside a Locked/closed period raises an Exception record, notifies HR, and does not overwrite the period's locked totals or close snapshot — the accepted figures stay trustworthy for downstream payroll/billing.
  • The close snapshot (timesheet_fact row with closed=1) is immutable: reconciliation skips writing once closed=1, so a closed period's reported totals are stable under later edits.
  • Exception raising is idempotent — the ts-exception:{issueKey}:{worklogId} set-once marker prevents duplicate Exception issues when the same event re-fires.
  • Re-opening a Locked period is HR/admin only, requires a non-empty comment, and is recorded in the approval trail and audit_event; re-submission re-runs validation + approval.

9.5 Privacy

No regulated personal data is stored in any timesheet fact, Exception record, approval trail, audit row, or notification. These hold only operational hours/seconds, counts, identity references (accountId), categories, period keys, and non-regulated free-text comments, consistent with the Phase 0 privacy rules. The worklog hrms-category property is operational, not regulated. Egress remains empty.

9.6 Threat notes

  • Spoofed time attribution — worklogs written asUser; author identity is authoritative; covered by grid integration tests.
  • Client-tampered submit (below-minimum / uncategorised) — re-validated server-side; covered by lifecycle tests.
  • Tampering with accepted totals after lock — detective guard (Exception + immutable snapshot); covered by lock integrity tests.
  • Unauthorized re-open — HR/admin authority re-check + comment requirement; covered by negative tests.
  • Duplicate / out-of-order worklog events — recompute-from-source is idempotent/convergent; exception dedupe marker; covered by reconcile tests.

Phase 3 — Reporting and Analytics

Scope review (no new scopes)

Phase 3 adds no Jira scopes. Reporting reads the existing Forge SQL/Storage via storage:app; org/team + group membership resolve via the existing read:jira-user / read:jira-work. The new jira:dashboardGadget and webTrigger modules require no scope. external.fetch.backend stays [] — the payroll/BI export is an inbound pull, never an outbound push.

Permission-scope model

Every report/dashboard/gadget/export read path resolves the caller's scope server-side through one pure, auditable function (reportScope) that yields a narrow-only account_id predicate: employee to self; manager to self + direct reports; HR/Finance/Admin/Executive to org-wide. Caller filters intersect the scope — they can never widen it; an empty intersection returns an authorised-empty result (1=0), never an error revealing out-of-scope existence. The UI is never the authority.

Cost visibility (no regulated PII)

Cost/liability figures use a per-cost-band day-rate proxy held in access-restricted admin config — never salary or per-person compensation. A canSeeCost flag (admin/Finance/HR/Executive) gates every cost figure; cost-requiring reports are denied to others. A privacy test asserts that serialized report/export/config outputs contain no regulated-PII field names and no PII values (it scans the emitted JSON for banned key names and for value patterns such as email/SSN/IBAN).

Export web-trigger threat model

Trust boundary = the public hrms-export web-trigger URL. Controls: (1) unauthenticated/invalid-token calls are rejected (401) before any query; (2) authorisation is scope-narrowed via reportScope — an over-scope request is narrowed, never errors-open; (3) the app-issued token lives in Forge secure storage, validated server-side, rotatable; (4) all SQL is parameterised (injection-safe); (5) amounts are never summed across currencies.

Phase 4 — Aurora Custom UI security posture

The UI redesign changes how surfaces render, never what the app may do. The bank-grade posture is identical to before, with one documented, justified deviation:

  • Egress stays empty. external.fetch.backend: [] is unchanged and no external.images/fonts/styles entries are added — every asset is bundled and self-hosted. A lint rule forbids external URLs in frontend code as the egress tripwire.
  • No new Jira scopes. Custom UI requires none; the manifest scope set is unchanged.
  • Server-side authority is untouched. All role / reportScope / canSeeCost gating stays in the resolvers. The UI only renders what comes back; the typed invoke() wrapper normalizes every failure to a user-safe response (no stack trace / internal detail leaked).
  • No new persisted data. Theme is derived live from the Jira theme token and never stored; no new Storage keys, SQL, or migrations.
  • No regulated PII enters the frontend; cost remains the per-cost-band proxy and renders only where the server already permits.
  • The one CSP relaxation: permissions.content.styles: ['unsafe-inline']. Recharts, Framer Motion, and several Radix primitives set inline element style attributes at runtime, which a strict style-src would block. This allowance is styles only, applies inside the sandboxed Custom UI iframe, and is not network egress and not a scope. Scripts stay locked.

Phase 5 — People and Policies security posture

Phase 5 adds admin capability and rewires who-approves-what; the bank-grade posture is unchanged:

  • Egress stays empty (external.fetch.backend: []) and no new scopes — the Users directory uses the already-granted read:jira-user.
  • Server-side authority preserved. Every config write is admin-gated and audited (immutable audit_event). Approver authority + All/Any quorum are enforced in the post-function / request engine over the append-only approval_event — the UI only configures/renders. Self-approval is off by default; enabling it is an explicit, audited per-user opt-in.
  • No regulated PII enters the frontend or new storage: the user directory projection drops email and every other field (accountId + display name + avatar only); audit detail carries ids/modes/counts/flags only — never names, emails, or free-text reason.
  • New config is KVS, admin-gated, validated by hand-rolled validators before any write; index records back safe enumeration. Policy deletion is blocked while assigned (IN_USE) and the default leave policy is PROTECTED.
  • No new dependencies; SCA/secret/license gates stay clean. Migration is additive/idempotent — no data rewrite of existing facts/reports/balances.

Phase 6 — Employee Timesheet Logging

  • No new scopes, no new egress: reading assigned tasks uses read:jira-work; the Feature worklog mirror uses write:jira-work — both already granted. external.fetch.backend stays [].
  • Server-side authority: the submitter identity comes only from the Forge context (never the payload); a person may act only on their own logs; a Feature log's linked task is re-verified server-side as accessible to the caller — the client is never trusted. Approver authority + All/Any quorum + HR/admin fallback + self-approval gating are enforced server-side; a non-approver decision is a no-op.
  • PII posture: logs store accountId + config + business text only (no email/salary). description, comment, and a rejection reason are access-scoped business content and are never written to audit_event. Every state change writes one immutable audit row with actor + action + time + PII-free detail.
  • Injection-safe: all timesheet_log access is parameterised; the assigned-tasks JQL bounds the user term to a single escaped clause with no free-form passthrough.
  • No new dependencies; SCA/secret/license gates stay clean.

Phase 9 — Workspace security posture

Phase 9 adds an admin-editable list of org names. The controlling risk is that such a list quietly becomes an authorization surface; the posture below exists mainly to stop that.

  • A Role is descriptive only and confers no permissions. Authorisation remains driven solely by Jira group membership (resolveRole); assigning someone the Role named "HR Manager" grants them nothing. This is pinned two ways in tests: behavioural — assigning or removing an "HR Manager" role changes no permission outcome; and structural — permissions.ts and resolverGuard.ts reference no workspace concept at all.
  • Enumeration is admin-only; own-assignment resolution is not. An employee can resolve the names of the entries assigned to them and nothing else: workspace.mine reads the taxonomy server-side and filters to the caller's own ids before building the response, so an employee can never enumerate a list, learn its size, or learn its version. workspace.mine takes no accountId payload by design — reading another person's workspace is not expressible.
  • Capacity and IN_USE are server-enforced. The ≤100% sum, the integer 0–100 bound, the "no allocation for an unheld squad" rule and the delete-while-assigned refusal all live in the resolver path; the admin UI's live total and disabled Save button are conveniences only. Tests prove it by hand-crafting a 110% payload with the UI bypassed — refused, with nothing partially persisted.
  • No new scope, no new egress, no regulated PII. Taxonomy + profile use storage:app, directory names read:jira-user, the task picker read:jira-work — all already granted; external.fetch.backend stays []. The taxonomy is org metadata, not personal data; people are accountId + display name + avatar only.

Phase 11 — Roster access model and the one new sensitive scope

Access model. The HRMS roster (hrms_member) gates app access: accessGuard.requireMember/isProvisioned is the server-side chokepoint (identity from Forge context only, never client input). Administrators always pass via role, so a Clear/replace can never lock admins out, and the roster is bootstrap-seeded from existing profiles on first upgrade so nobody is locked out. Every surface calls the public session.status and renders a "not provisioned" state instead of erroring. The gate is enforced server-side on the member-facing write paths and the report builder.

New scope read:email-address:jira — justified, minimal, transient, admin-only, audited:

  • Why: the app cannot create Atlassian accounts and has no email to accountId lookup, so bulk-granting access to existing instance users requires reading emails to match them. This is the only new scope.
  • Transient: email is read at import time, used to resolve an accountId, then dropped immediately — never written to hrms_member / people-profile. No regulated PII is stored.
  • Degrade loudly: if the instance hides emails, the import fails with an actionable message and makes no partial changes.
  • Audited: every import/clear/delete writes an immutable audit_event with action + counts only — the audit guard rejects reason/regulated-PII keys, and email is never audited.

Egress unchanged: external.fetch.backend stays []; the email reads use the Forge-brokered api.asApp().requestJira, not raw egress.

Phase 12 — Overtime, Per-User Reports and Overview Metrics

No new scope; egress stays []; no new runtime dependency. The only manifest change is one hourly scheduledTrigger.

Server-side authority: every new read/decision is gated from the platform-verified caller, never client payload. overtime.mine/overtime.today refuse a non-enabled caller. overtimeApproval.approve/reject re-check mayDecide and evaluate All/Any quorum over the append-only approval_event. userReport.export is admin-gated. The finalization trigger runs asApp with no client input.

Immutability: finalized overtime_day rows never change amount/status from a later worklog edit — the finalizer only ever inserts (never updates), and re-runs skip already-finalized days.

Idempotency: the (account_id, ot_date) PK makes repeated/catch-up runs produce zero duplicate rows/requests/audits. Audit: finalize/approve/reject/export each write one immutable, PII-free audit_event (aggregate seconds + codes only). Data minimization: overtime_day, the finalizer, and exports carry no email/name/reason. Exports are formula-injection-safe and downloaded as a self-hosted Blob (no external URL).

Phase 13 — Jira-Native UI Retheme

The Phase 13 UI retheme is presentational only and does not alter the security posture:

  • No new attack surface: no Jira scope added, external.fetch.backend stays [], no new resolver/endpoint, no new data stored/transmitted or new PII. The only manifest.yml change is the presentational layout: blank on the hub pages — it adds no scope, no egress, no permission, and no new module.
  • Dependency reduced, not added: the bundled font package was removed; text uses the OS/Atlassian system font stack (no external/CDN font request — egress stays []). No new runtime dependency was introduced.
  • Server-side authority + immutable audit are untouched (backend frozen).
  • Accessibility (WCAG 2.1 AA focus + contrast) is preserved/improved in both themes.

Phase 14 — Hub and Admin UX Refinements

Phase 14 is UI reshaping plus two read-only resolver keys; it does not change the security posture:

  • No new attack surface: no Jira scope added, external.fetch.backend stays [], no manifest.yml change, no new module, no stored data, no SQL/migration, no new PII.
  • workProfile.mine is self-scoped: identity comes from the Forge context and it returns only the caller's own hours/weekend config. Read-only — no state change, no audit record.
  • requests.decisionHistory is approver-scoped: it returns only decisions the caller made, reading the existing append-only decision log, and exposes no field beyond what the live approver queues already show. Read-only — no state change, no audit record.
  • Authority + audit untouched: every approve/reject still flows through the existing decision resolvers with their server-side eligibility + All/Any quorum checks; the inbox is presentation only.

Phase 18 — Reports Restructuring and User Activation Control

Guardrails held: egress stays [], no new Jira scope, no new runtime dependency, no manifest.yml change, no new SQL table/column/migration. Reports are reads and are deliberately not audited; the activation toggle is a state change and is audited.

The reporting trust boundary

Four properties, enforced in this order on every call:

  1. Identity is never client-supplied. No handler in the reports resolver accepts an accountId in its payload. For the personal tier the vector is designed out rather than validated away — the scope is [caller] from the platform context. Tests prove a spoofed accountId payload changes nothing.
  2. The registry is the allowlist. getReport(reportId) resolves against a frozen Map, so an unknown id, a path-traversal string, a SQL fragment, or a prototype key is refused before any query is constructed.
  3. Tier authority is re-checked server-side, regardless of what the interface displayed: team is refused unless the caller manages someone; admin unless the caller is an administrator. reportTier.list returns an empty list for an unauthorised tier rather than an error, so the response never confirms the tier exists.
  4. Scope can only narrow. Every account set is compiled through the existing accountPredicate. Structure filters intersect; an out-of-reach or unknown structure yields an empty report, never an error and never a widening.

Injection: every value is a bound parameter. The only interpolated fragments are the predicate's own clause (built from a hard-coded column plus placeholders) and the LIMIT, which is clamped to a finite integer first. Every read is bounded; when the ceiling applies the result is marked truncated.

Export injection posture: CSV keeps the existing formula-neutralising prefix; XLSX is structurally immune (a formula requires an explicit element and the writer emits only inline strings and numbers); PDF is not executable and escapes literal strings. Both new writers are pure, in-repo and unit-tested.

Privacy: a structural guard test scans every tier's serialised result and all three export artifacts (decoded) for banned key names and banned value shapes, and asserts no read selects free-text business content.

The activation trust boundary

Enforcement is at one chokepoint: accessGuard.isProvisioned / requireMember, through which every user-facing resolver already passes. Because there is no gated handler that bypasses it, "a deactivated person is refused, not merely hidden" is true for every operation by construction rather than by enumeration. A distinct DEACTIVATED code lets the shell render the lock screen instead of a generic refusal, and neither refusal leaks anything about anyone else.

Absent means active. Only an explicit stored false deactivates, and the validator rejects a non-boolean rather than coercing it — coercion is precisely how an accidental instance-wide lockout would happen.

Anti-lockout, documented and deliberate, in two layers:

  • peopleConfig.setActive refuses self-deactivation — no single click can strand the console.
  • Administrators retain the role bypass they already have for the roster, so a mistaken deactivation by a peer can always be undone.

Audit: every state change writes an immutable record after the durable write (actor, subject, new state, timestamp) containing no personal data. Actor and subject are opaque Atlassian account IDs — never names, emails, or any regulated identifier. A no-op change writes no duplicate record, so a double-click cannot pollute the trail.

No work is stranded, no history is lost: deactivation re-routes that person's pending approvals to the fallback approver, and edits or deletes nothing — their records remain and continue to appear in reports covering past periods.

HEROSAPPS

Blomberger Str., Detmold 32756, Germany

Securitysecurity@herosapps.com

This document describes the security posture of the HRMS Hero app as published on the Atlassian Marketplace. It is kept current with every change to the app's scopes, data flows, identity model, and security gates.

HEROSAPPS

Premium Atlassian Jira apps.

HRMS Hero
coming soon Reporter Hero Planner Hero
Wiki Ideas About Us Contact
Privacy Policy Terms of Use Security DPA SLA

© 2026 HEROSAPPS. All rights reserved.

Built for Atlassian Jira Cloud.