"""Static catalogs for the security-awareness domain. Everything in here is a **stable machine key**, never a user-facing string: the frontend renders ``t('securityAwareness.category.')`` and ``t('securityAwareness.kind.')``. Renaming a key is a data migration (stored rows carry it), so treat this file as append-mostly. """ from __future__ import annotations from typing import Final # ── Material kinds ────────────────────────────────────────────────── # ``comic`` — one or more images read in order (漫画 / 海报 / 长图). # ``article`` — markdown body rendered inline. # ``link`` — the material lives elsewhere (Feishu doc, wiki, video); # Helix carries the card + the exposure ledger, and the # click leaves. Kept as a first-class kind because most # existing security content already lives in Feishu docs # and forcing a copy into Helix would guarantee drift. KIND_COMIC: Final[str] = "comic" KIND_ARTICLE: Final[str] = "article" KIND_LINK: Final[str] = "link" MATERIAL_KINDS: Final[frozenset[str]] = frozenset({ KIND_COMIC, KIND_ARTICLE, KIND_LINK, }) # ── Lifecycle ─────────────────────────────────────────────────────── # ``draft`` — invisible to employees; editable freely. # ``published`` — visible in the employee learning centre and eligible # to be attached to a campaign. # ``archived`` — no longer surfaced anywhere new, but historical # impressions / campaign targets keep pointing at it. # Archiving is how a material is retired: deleting one # that has already been shown would destroy the evidence # that it WAS shown, which is the whole point of the # ledger. STATUS_DRAFT: Final[str] = "draft" STATUS_PUBLISHED: Final[str] = "published" STATUS_ARCHIVED: Final[str] = "archived" MATERIAL_STATUSES: Final[frozenset[str]] = frozenset({ STATUS_DRAFT, STATUS_PUBLISHED, STATUS_ARCHIVED, }) # ── Categories ────────────────────────────────────────────────────── # Deliberately a short, closed list. A free-text category field turns # into forty spellings of "钓鱼" within a year and makes the coverage # report unable to answer "how much phishing training did we run". CATEGORY_PHISHING: Final[str] = "phishing" CATEGORY_ACCOUNT: Final[str] = "account" CATEGORY_DATA: Final[str] = "data" CATEGORY_ENDPOINT: Final[str] = "endpoint" CATEGORY_POLICY: Final[str] = "policy" CATEGORY_INCIDENT: Final[str] = "incident" CATEGORY_DEV: Final[str] = "dev" CATEGORY_OTHER: Final[str] = "other" MATERIAL_CATEGORIES: Final[tuple[str, ...]] = ( CATEGORY_PHISHING, # 钓鱼与社工 CATEGORY_ACCOUNT, # 账号与密码 CATEGORY_DATA, # 数据分级与保密 CATEGORY_ENDPOINT, # 终端与办公安全 CATEGORY_POLICY, # 合规与制度 CATEGORY_INCIDENT, # 应急与上报 CATEGORY_DEV, # 研发安全 CATEGORY_OTHER, # 其它 ) # ── Impression surfaces ───────────────────────────────────────────── # Where an exposure happened. The ledger is the compliance artefact for # "this person was shown this content", so the surface has to be recorded # — "saw it in a Feishu card" and "opened the full article" are different # claims and an auditor is entitled to tell them apart. SURFACE_DETAIL: Final[str] = "detail" # opened the material page SURFACE_BUBBLE: Final[str] = "bubble" # ambient mascot bubble SURFACE_CAMPAIGN_CARD: Final[str] = "campaign_card" # push card / mail IMPRESSION_SURFACES: Final[frozenset[str]] = frozenset({ SURFACE_DETAIL, SURFACE_BUBBLE, SURFACE_CAMPAIGN_CARD, }) # Surfaces a BROWSER is allowed to assert through # ``POST /security-awareness/impressions``. Deliberately just the ambient # bubble — the one place the server genuinely cannot observe what was # rendered. # # ``detail`` is excluded because it is not a decoration: it is the surface # that drives the library's read/unread marker, the ``opened`` column of # the participation report and the ``awareness_materials_read`` badge # fact. Accepting a client's word for it would let anybody mark fifteen # materials read without opening one, and the whole reason those numbers # separate "shown" from "read" is that the second one is supposed to be # the stronger claim. The server writes it itself, from the material read # route, where a human actually opened the page. # # ``campaign_card`` is excluded because a self-reported "I saw the push # card" is not evidence of anything: whether a campaign message reached # somebody is the outbox's ``delivered``/``partial`` status, which owns # the retry ladder and the leaver gate. Mirroring that into the exposure # ledger is exactly the mirror ``campaign_service``'s module docstring # forbids. CLIENT_REPORTABLE_SURFACES: Final[frozenset[str]] = frozenset({ SURFACE_BUBBLE, }) # Two exposures of the same (person, material, surface) inside this # window collapse into one ledger row. Without it the ambient bubble — # which re-renders every few seconds and on every route change — would # write millions of rows saying nothing, and "shown 400 times" would # stop meaning anything to a reader of the coverage report. The window # is per-surface-agnostic on purpose: one number is auditable, a table # of per-surface windows is a thing nobody can explain in a review. IMPRESSION_DEDUPE_MINUTES: Final[int] = 30 # ── Languages ─────────────────────────────────────────────────────── # The three languages the platform speaks. ``zh`` is the baseline: a # material MUST carry it, because ``app.core.locale.FALLBACK_CHAIN`` # terminates there and a material with no reachable content is a broken # card for somebody. LANG_ZH: Final[str] = "zh" LANG_EN: Final[str] = "en" LANG_JA: Final[str] = "ja" MATERIAL_LANGS: Final[tuple[str, ...]] = (LANG_ZH, LANG_EN, LANG_JA) BASELINE_LANG: Final[str] = LANG_ZH # Per-language resolution order. Mirrors ``app.core.locale.FALLBACK_CHAIN`` # (ja → en → zh) so an employee reading Japanese sees the English version # of a material that has no Japanese copy rather than an empty card. # Duplicated here rather than imported because this table is about # *content* fallback (which localized block do we render) while # ``locale.py`` is about *recipient* language resolution (which language # do we speak to this person in); they agree today and are allowed to # diverge later without one silently rewriting the other. LANG_FALLBACK: Final[dict[str, tuple[str, ...]]] = { LANG_ZH: (LANG_ZH, LANG_EN, LANG_JA), LANG_EN: (LANG_EN, LANG_ZH, LANG_JA), LANG_JA: (LANG_JA, LANG_EN, LANG_ZH), } # ── Content caps ──────────────────────────────────────────────────── MAX_TITLE_LEN: Final[int] = 200 MAX_SUMMARY_LEN: Final[int] = 500 MAX_BODY_LEN: Final[int] = 60_000 MAX_LINK_LEN: Final[int] = 1000 MAX_IMAGES_PER_LANG: Final[int] = 40 MAX_TAGS: Final[int] = 20 MAX_TAG_LEN: Final[int] = 50 # Reading-time estimate when the admin leaves it blank. Chinese prose # reads at roughly 300–500 characters/minute; 400 is the middle and the # number only ever drives a "约 N 分钟" hint, never a gate. CHARS_PER_MINUTE: Final[int] = 400 # A comic page is looked at, not read. Same idea, coarser unit. SECONDS_PER_COMIC_PAGE: Final[int] = 20 # ── Campaign lifecycle (PR2) ──────────────────────────────────────── # ``draft`` — editable, nothing sent, no targets exist yet. # ``scheduled`` — waiting for ``scheduled_at``; the dispatch cron owns it. # ``sending`` — a dispatch is in flight. The state itself is the lock: # the transition to it is a conditional UPDATE, so a # second cron tick landing on the same row does nothing. # ``sent`` — every target row exists and was handed to the outbox. # ``closed`` — the admin declared it finished; it stops appearing as # outstanding work and stops accepting acknowledgements. # ``cancelled`` — called off before sending. Distinct from ``closed`` # because "we decided not to run this" and "this ran and # is over" are different facts in a coverage report. CAMPAIGN_DRAFT: Final[str] = "draft" CAMPAIGN_SCHEDULED: Final[str] = "scheduled" CAMPAIGN_SENDING: Final[str] = "sending" CAMPAIGN_SENT: Final[str] = "sent" CAMPAIGN_CLOSED: Final[str] = "closed" CAMPAIGN_CANCELLED: Final[str] = "cancelled" CAMPAIGN_STATUSES: Final[frozenset[str]] = frozenset({ CAMPAIGN_DRAFT, CAMPAIGN_SCHEDULED, CAMPAIGN_SENDING, CAMPAIGN_SENT, CAMPAIGN_CLOSED, CAMPAIGN_CANCELLED, }) #: Statuses in which the campaign has actually reached people. CAMPAIGN_LIVE_STATUSES: Final[frozenset[str]] = frozenset({ CAMPAIGN_SENT, CAMPAIGN_CLOSED, }) # ── Completion modes ──────────────────────────────────────────────── # What counts as "this person is done with this campaign". # ``impression`` — we showed it to them (the weakest claim; the card # landing in their Feishu is enough). # ``open`` — they opened the material. # ``ack`` — they pressed "我已阅读并理解". The only one an # auditor generally accepts, and the default. COMPLETION_IMPRESSION: Final[str] = "impression" COMPLETION_OPEN: Final[str] = "open" COMPLETION_ACK: Final[str] = "ack" COMPLETION_MODES: Final[tuple[str, ...]] = ( COMPLETION_IMPRESSION, COMPLETION_OPEN, COMPLETION_ACK, ) # ── Delivery channels ─────────────────────────────────────────────── # ``inbox`` is not optional in practice — the in-app bell is the channel # of last resort and every dispatch writes an outbox row regardless — so # it is listed for completeness but the service always includes it. CHANNEL_FEISHU: Final[str] = "feishu" CHANNEL_EMAIL: Final[str] = "email" CAMPAIGN_CHANNELS: Final[tuple[str, ...]] = (CHANNEL_FEISHU, CHANNEL_EMAIL) # ── Acknowledgement sources ───────────────────────────────────────── ACK_WEB: Final[str] = "web" ACK_MOBILE: Final[str] = "mobile" ACK_MCP: Final[str] = "mcp" ACK_SOURCES: Final[frozenset[str]] = frozenset({ACK_WEB, ACK_MOBILE, ACK_MCP}) # ── Notification template keys ────────────────────────────────────── # TWO keys, not one with a flag, because they are classified into # different notification categories and that classification is what # decides whether an employee can turn the push off: # * mandatory → ``CAT_SECURITY`` (immutable, action bucket) # * voluntary → ``CAT_AWARENESS`` (mutable, info bucket) # See ``app/notifications/categories.py``. Collapsing them into one key # would make every awareness push un-mutable, which is how a channel # gets muted. TEMPLATE_MANDATORY: Final[str] = "security_awareness.campaign.mandatory" TEMPLATE_NOTICE: Final[str] = "security_awareness.campaign.notice" #: ``related_resource_type`` stamped on every outbox row a campaign #: produces. The campaign detail read joins the outbox on this + the #: campaign id to report delivery truth, rather than mirroring a #: delivery column onto the target row. CAMPAIGN_RESOURCE_TYPE: Final[str] = "secaw_campaign" #: Hard ceiling on one campaign's audience. Not a scaling limit — the #: company is ~500 people — but a blast-radius guard: a mis-built filter #: that resolves to "everyone, twice" should fail loudly at preview time #: rather than page 900 people at 3am. MAX_AUDIENCE: Final[int] = 2000