AACsearch
Arquitectura

Tipos de claves y modelo de seguridad

Las cuatro categorías de claves de AACsearch — search, connector, scoped y admin — sus prefijos ss_*, almacenamiento solo-hash, HMAC + TTL + filtro del scoped-token y cómo se verifica cada una en tiempo de petición.

AACsearch emite una fila física por credencial en SearchApiKey, diferenciada por la columna scopes[]. La cadena cruda de la clave nunca se almacena — solo un hash sha256. Los scoped-tokens son una cuarta categoría derivada que vive enteramente en la cadena bearer (sin fila) y se verifica por HMAC.

Las cuatro categorías de claves

Descripción

El diagrama agrupa las credenciales de AACsearch en dos buckets: tres tipos de fila persistidos en SearchApiKey (admin, ingest/connector, search) — cada uno guardado solo como hash sha256 con su propio prefijo y conjunto de scopes — y un tipo derivado de scoped-token que nunca se persiste, firmado con HMAC-SHA256 sobre BETTER_AUTH_SECRET. Las flechas anotan la superficie de despliegue prevista (solo servidor, CMS, backend de confianza, widget de navegador) y muestran que los scoped-tokens heredan de una clave search padre.

graph TD
    classDef admin fill:#fde68a,stroke:#b45309,color:#78350f
    classDef ingest fill:#bae6fd,stroke:#0369a1,color:#075985
    classDef search fill:#bbf7d0,stroke:#15803d,color:#14532d
    classDef scoped fill:#ddd6fe,stroke:#6d28d9,color:#4c1d95

    subgraph Persisted["SearchApiKey rows — sha256(raw) only"]
        Admin["Admin key<br/>prefix: ss_search_<br/>scopes: [admin]<br/>full index control"]:::admin
        Ingest["Ingest / Connector key<br/>prefix: ss_connector_ (or ss_search_)<br/>scopes: [connector_write] / [ingest]<br/>writes to SearchSyncOutbox"]:::ingest
        Search["Search key<br/>prefix: ss_search_<br/>scopes: [search]<br/>read-only, origin-restricted"]:::search
    end

    subgraph Derived["Not persisted — verified by HMAC"]
        Scoped["Scoped token<br/>prefix: ss_scoped_<br/>payload: { keyId, parentRawKey, filterBy, exp }<br/>HMAC-SHA256 over BETTER_AUTH_SECRET"]:::scoped
    end

    Search -. parent of .-> Scoped

    UseAdmin["Server-side only<br/>NEVER in browser/CMS"] --- Admin
    UseIngest["CMS connector / sync job"] --- Ingest
    UseSearch["Server-rendered search<br/>(trusted backend)"] --- Search
    UseScoped["Browser widget / per-user filter<br/>(short-lived, embeddable)"] --- Scoped

Flujo de verificación por petición

Descripción

El diagrama es el flujo de verificación por petición: el prefijo despacha o bien a verificar-HMAC-y-luego-hashear (para ss_scoped_*) o bien a hash-y-lookup directo (para ss_search_* / ss_connector_*), seguido del chequeo de scope, allow-list de origen, bucket de rate-limit y, finalmente, la combinación AND del filtro de tenant antes de que se ejecute el handler. Cualquier fallo corta el flujo con 401, 403 o 429.

flowchart TD
    Req["Request: Authorization: Bearer &lt;token&gt;"]
    Shape{"Prefix?"}
    Scoped["ss_scoped_*"]
    Search["ss_search_*"]
    Connector["ss_connector_*"]
    Reject["401 missing_bearer_token"]

    HMAC["1. base64url decode payload<br/>2. HMAC-SHA256(payload, BETTER_AUTH_SECRET)<br/>3. timingSafeEqual(sig, expected)<br/>4. exp &gt; now()"]
    Hash["sha256(normalizeKeyForHash(raw))"]
    Lookup["SELECT * FROM search_api_key<br/>WHERE hash = ?<br/>AND revokedAt IS NULL<br/>AND deletedAt IS NULL"]
    ScopeCheck{"Required scope<br/>in scopes[]?"}
    OriginCheck{"Origin in<br/>allowedOrigins?"}
    RateLimit{"RateLimitBucket<br/>&le; rateLimitPerMinute?"}
    Filter["Combine: userFilter && tenantId:&lt;org&gt; && (scopedFilter)"]
    Allow["Proceed to handler"]

    Req --> Shape
    Shape --> Scoped --> HMAC --> Hash
    Shape --> Search --> Hash
    Shape --> Connector --> Hash
    Shape -. unknown .-> Reject

    Hash --> Lookup --> ScopeCheck
    ScopeCheck -- no --> Reject
    ScopeCheck -- yes --> OriginCheck
    OriginCheck -- no --> Reject
    OriginCheck -- yes --> RateLimit
    RateLimit -- 429 --> Reject
    RateLimit -- ok --> Filter --> Allow

Reglas de almacenamiento

PropiedadAdminIngest / ConnectorSearchScoped
Prefijoss_search_*ss_connector_* (o ss_search_*)ss_search_*ss_scoped_*
¿Persistido en SearchApiKey?No — derivado
Almacenado comohash sha256hash sha256 (espacio compartido)hash sha256Carga útil firmada autocontenida
Scope requeridoadminconnector_write / ingestsearchHereda del padre (debe ser search)
ExpiraciónexpiresAt opcionalexpiresAt opcionalexpiresAt opcionalexp obligatorio (≤ 24 h, típ. 15 min)
¿Filtro de tenant aplicado?n/a (admin)Auto-inyectado al escribirAuto-inyectado al buscarAuto-inyectado más filtro scoped
¿Permitido en el navegador?NuncaNuncaSolo si está origin-lockedSí (uso previsto)
RevocaciónrevokedAtrevokedAtrevokedAtEsperar exp (o rotar el padre)

Hard invariants que esto impone

  • #3 Almacenamiento solo-hash. Ningún code path persiste la clave cruda. hashSearchApiKey() es la única función que toca el secreto, y devuelve un digest hex.
  • #4 Scoped-tokens. Cada caja de búsqueda orientada al cliente usa un token ss_scoped_*, no una clave ss_search_* de larga vida. HMAC + TTL + filtro es innegociable.
  • #5 Aislamiento de tenant. tenantId:<org> se combina por AND dentro de filter_by en el adaptador de búsqueda, nunca como cabecera — es a prueba de falsificación.

Relacionado

  • Ruta de lectura — cómo encaja la cadena de verificación en el pipeline de peticiones.
  • Ruta de escritura — cómo las claves de ingesta alimentan la outbox.

On this page