Aller au contenu principal

Intégrer un tableau de bord

L'intégration est le moment où un tableau de bord cesse d'être quelque chose que votre équipe ouvre et devient une partie d'un produit que vos clients utilisent. Vibedasher a deux façons de le faire, et la différence n'est pas cosmétique — elle décide qui peut lire les données.

Voie publiqueVoie à jeton signé
view_mode du tableau de bordpublicprotected
Qui peut voirn'importe qui avec l'URLseulement un visionneur pour lequel votre backend a généré un jeton
Filtrage de ligne par visionneurnonoui, appliqué côté serveur
Ce que vous intégrezune URLune URL plus un jeton de courte durée

Un tableau de bord private n'est intégrable par aucune des deux voies. Les deux voies exigent que le tableau de bord soit d'abord publié — voir Publier et partager.

L'intégration est l'une des deux réponses

L'intégration met notre tableau de bord rendu dans un rectangle de votre page. L'autre réponse est d'éjecter — prendre la source React et SQL et la rendre vous-même, nativement, avec nous hors du chemin de rendu et seulement le moteur de données en dessous. Celle des deux que vous voulez se décide surtout par deux questions : est-ce que des visionneurs différents doivent voir des lignes différentes (l'intégration est la seule voie qui le fait), et est-ce que le tableau de bord doit avoir l'apparence et le comportement du reste de votre application (seule l'éjection vous donne cela). Les compromis sont mis côte à côte dans Intégrer ou éjecter.

La voie publique​

Si le tableau de bord est publié avec view_mode: public, une iframe est toute l'intégration :

<iframe
src="https://cloud.vibedasher.com/v/<slug>"
width="100%"
height="800"
style="border: 0;"
title="Vibedasher dashboard"
sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"
allowfullscreen
loading="lazy"
></iframe>

allow-same-origin paraît alarmant à côté de allow-scripts et est sûr ici précisément parce que le cadre est cross-origin par rapport à votre page : il accorde au moteur de rendu sa propre origine, pas la vôtre. allow-top-navigation est délibérément absent, donc le cadre ne peut pas faire naviguer votre page ailleurs. allow-downloads est ce qui fait fonctionner l'export PNG de graphique.

L'extrait fonctionne depuis n'importe quelle page que vous contrôlez — rien n'a à être configuré de notre côté d'abord. L'audience sous laquelle vous publiez est le contrôle qui compte ici, et La frontière du cadre est l'endroit où ce choix est exposé.

La boîte de dialogue Partager de l'application vous donne cet extrait pré-rempli. Récupérez-le là plutôt que d'assembler l'URL à la main.

La voie à jeton signé​

C'est celle qui mérite d'être comprise, car c'est le seul endroit où Vibedasher applique la sécurité au niveau des lignes.

Votre backend génère le jeton — vous ne signez rien​

Vibedasher signe le jeton lui-même, en ES256, avec sa propre clé rotative. Votre backend en demande un avec sa clé API :

from vibedasher.models import EmbedTokenRequest, EmbedTokenRequestFilters

resp = client.embed_tokens.create_embed(
EmbedTokenRequest(
viz_id=1234,
filters=EmbedTokenRequestFilters.from_dict({"region": "EMEA"}),
ttl_seconds=600,
)
)
print(resp.token, resp.expires_in)
const resp = await client.embedTokens.createEmbed({
vizId: 1234,
filters: { region: 'EMEA' },
ttlSeconds: 600,
});
Il n'existe pas de « secret d'intégration »

Vous ne détenez aucun matériel de signature et n'en avez pas besoin — générez vos jetons comme indiqué ici.

Ou en HTTP brut, depuis n'importe quel langage :

curl -X POST https://api.vibedasher.com/api/v1/embed/token \
-H "X-Api-Key: $VIBEDASHER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"vizId": 1234, "ttlSeconds": 600, "filters": {"region": "EMEA"}}'
# → {"token": "<ES256 JWT>", "token_type": "embed", "expires_in": 600}

Le corps de la requête accepte :

ChampRequisNotes
vizIdouiExactement un tableau de bord. Un jeton ne peut pas couvrir un dossier ou un compte.
filtersnonLes filtres de ligne. Voir ci-dessous.
viewerIdnonUn identifiant de siège opaque. Audit et mesure d'usage seulement — ce n'est pas un paramètre de contrôle d'accès. Ne comptez pas dessus pour scoper quoi que ce soit.
ttlSecondsnonDéfaut 600. Plancher 60, plafond dur 86 400 (24 h). Les valeurs hors plage sont plafonnées à la génération et revérifiées à la vérification. La vérification tolère une petite marge de dérive d'horloge au-delà de exp, plafonnée à 120 s et à la moitié de la durée de vie du jeton lui-même — la durée de vie réellement appliquée ne dépasse donc jamais 1,5× expires_in, et à partir d'un TTL de 240 s la marge est le plafond fixe de 120 s.
apiKeyIdnonSeulement nécessaire lors de la génération depuis une session de navigateur, pour lier le jeton à la liste blanche de domaines d'une clé et à sa révocation.

Le account_id est estampillé à partir de votre identifiant et n'est jamais lu depuis le corps de la requête.

La génération échoue avec un message qui nomme le contrôle en échec — vous générez un jeton, vous n'en présentez pas un, donc l'erreur vous dit quoi changer sur le tableau de bord ou la clé :

StatutDétailCorrectif
403la visualisation appartient à un autre comptegénérez avec une clé API du compte qui la détient
403la visualisation n'est pas intégrable avec un jeton : sa visibilité doit être protectedpassez la visibilité du tableau de bord sur Protégé dans la boîte de dialogue Partager
404la visualisation n'est pas publiéepubliez-la d'abord
403aucune clé API utilisable pour lier le jeton d'intégrationla clé est absente, inactive, expirée ou appartient à un autre compte
404Viz introuvablevérifiez vizId

Un tableau de bord public est rejeté par cette voie — il a déjà la voie anonyme, et mélanger les deux serait une façon de croire que vous avez des filtres de ligne alors que non.

Filtres de ligne​

filters est une correspondance entre un nom de colonne et un scalaire ou une liste. Un scalaire devient =, une liste devient IN, et plusieurs clés sont combinées par ET. Les clés sont le sqlName de la colonne tel que GET /v1/datasets/{id} le rapporte — le nom que la vue SQL expose, pas le nom d'affichage.

La plateforme n'ajoute pas de clause WHERE à votre requête ; elle réécrit chaque référence de jeu de données en une relation filtrée, en conservant l'alias d'origine :

FROM orders
-> FROM (SELECT * FROM "orders" WHERE "tenant" = 'acme') AS orders

C'est ce qui rend la portée non élargissable, et pas seulement présente. Ce que la page demande s'applique à l'extérieur de la relation filtrée, donc elle ne peut que la restreindre davantage — WHERE tenant = 'globex' OR 1=1 ne voit toujours qu'un seul locataire, une sous-requête enveloppante enveloppe la relation filtrée, les deux branches d'un UNION sont réécrites, et une CTE qui masque l'alias, une référence qualifiée par schéma, ou toute fonction de lecture de fichier sont refusées d'emblée.

Trois propriétés rendent cela auditable :

  • Les filtres ne proviennent que du jeton vérifié. Ils ne sont jamais lus depuis le corps de la requête, donc un visionneur qui fouille l'onglet réseau ne peut pas élargir sa propre portée.
  • Une clé de filtre qui ne nomme aucune colonne des jeux de données du tableau de bord est refusée à la création — 403, en nommant la clé et en listant les colonnes qui existent. Aucun jeton n'est signé. Il en va de même pour une valeur qui ne peut pas être convertie au type de sa colonne. Un filtre qui correspondrait silencieusement à tout serait une fuite de données déguisée en contrôle de sécurité : il est donc refusé avant de pouvoir devenir un lien que vous remettez à un client.
  • La création vous dit ce que le jeton permet, dans le champ scope de sa réponse. Lisez-le ; ne le supposez pas.

Lisez scope.fullyScoped — ce n'est pas « les filtres ont été acceptés »​

Un tableau de bord peut lire plusieurs jeux de données, et un filtre ne peut s'appliquer qu'à un jeu de données qui possède cette colonne — on ne filtre pas sur une colonne qu'une table n'a pas. Ainsi un tableau de bord sur orders et price_list, avec la portée {"tenant": "acme"}, filtre orders et lit price_list en entier. La page se charge, chaque tuile se dessine, chaque nombre est plausible, et rien dans la requête n'a l'air anormal.

C'est correct et voulu quand le second jeu de données est une table de référence partagée — une grille tarifaire, un catalogue d'offres, un jeu de libellés. Ce n'est pas ce que vous voulez quand le second jeu de données contient des lignes par client sous une colonne au nom différent (tenant_id plutôt que tenant, customer, org). La réponse de création est l'endroit où vous découvrez dans quel cas vous êtes :

{
"token": "<ES256 JWT>", "token_type": "embed", "expires_in": 600,
"scope": {
"vizId": 1234,
"filtered": [{"datasetId": 7, "datasetName": "orders",
"predicate": "\"tenant\" = 'acme'", "columns": ["tenant"]}],
"unfiltered": [{"datasetId": 8, "datasetName": "price_list",
"predicate": null, "columns": []}],
"unresolvedDatasetIds": [],
"fullyScoped": false,
"meaning": "PARTIALLY SCOPED — read this before handing the link out. ..."
}
}
ChampQuoi en faire
fullyScopedCelui sur lequel asserter. true seulement quand chaque jeu de données lu par le tableau de bord est filtré par ces filtres. Tout le reste vaut false.
filtered[].predicateLe SQL exact appliqué à ce jeu de données. C'est la phrase à mettre devant un responsable sécurité.
unfiltered[]Les jeux de données que ce jeton lit en entier. Décidez, pour chacun, s'il s'agit d'une table de référence partagée ou d'une faille.
unresolvedDatasetIdsLes jeux de données dont le schéma n'a pas pu être lu. Ce n'est pas la même chose que « non filtré » : « lit tout » et « nous ne savons pas ce que ceci lit » sont deux réponses différentes, et aucune n'est fondue dans l'autre.

Une vérification de production raisonnable, dans votre route de création, consiste à refuser de renvoyer le jeton quand scope.fullyScoped vaut false alors que vous l'attendiez à true — vous savez lesquels de vos tableaux de bord sont censés être par client, et la plateforme, non.

Présenter le jeton​

Le cadre ne peut pas définir d'en-tête, donc le jeton voyage sur l'URL :

<iframe
src="https://cloud.vibedasher.com/v/<slug>?embed_token=<token>"
sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"
allowfullscreen
></iframe>

Les réponses d'intégration sont envoyées avec Referrer-Policy: no-referrer pour que le jeton ne fuite pas dans un en-tête Referer sur des sous-requêtes. Cela reste un identifiant porteur dans une URL, ce qui est la vraie raison pour laquelle le TTL par défaut est de dix minutes : générez-en un par chargement de page, gardez-le court, et rafraîchissez-le plutôt que d'augmenter le TTL.

Configurer votre liste blanche de domaines​

Une clé API peut porter une liste de domaines depuis lesquels ses jetons d'intégration seront honorés. Une liste vide signifie tout autoriser. C'est une décision délibérée pour que la fonctionnalité marche dès que vous l'essayez, et cela signifie qu'une clé non configurée ne protège rien au niveau de l'origine. Définissez allowed_domains sur la clé avant de passer en production.

La boucle de rafraîchissement de jeton​

Un jeton de dix minutes sur un tableau de bord que quelqu'un laisse ouvert tout l'après-midi a besoin d'être rafraîchi, et le moteur de rendu vous le signale :

  1. Le cadre envoie EMBED_TOKEN_EXPIRED (ou EMBED_TOKEN_INVALID) à votre page.
  2. Votre page demande un nouveau jeton à votre backend.
  3. Votre page envoie UPDATE_TOKEN dans le cadre : { type: 'UPDATE_TOKEN', token: '<new>' }.
window.addEventListener('message', async (e) => {
if (e.origin !== 'https://cloud.vibedasher.com') return;
if (e.data?.type === 'EMBED_TOKEN_EXPIRED' || e.data?.type === 'EMBED_TOKEN_INVALID') {
const token = await fetch('/api/vibedasher-embed-token').then((r) => r.text());
frame.contentWindow.postMessage({ type: 'UPDATE_TOKEN', token }, 'https://cloud.vibedasher.com');
}
});

Vérifiez toujours e.origin. UPDATE_TOKEN est le seul message qu'un hôte tiers peut envoyer vers l'intérieur — tout le reste est ignoré.

Ce que votre page reçoit réellement​

Les messages qu'un hôte tiers reçoit sont une liste blanche fixe de treize. Voici la liste complète :

MessageQuand
READYLe moteur de rendu a démarré.
LOADEDLe tableau de bord a fini de charger.
RESIZELa hauteur du contenu a changé — utilisez-le pour dimensionner l'iframe.
BUNDLE_STARTED / BUNDLE_COMPLETED / BUNDLE_FAILED / BUNDLE_RETRYCycle de vie du build du code du tableau de bord.
RUNTIME_ERRORLe code du tableau de bord a levé une erreur.
EMPTY_DATAUne requête n'a renvoyé aucune ligne.
RENDER_HEALTHSanté de rendu par tuile.
EMBED_TOKEN_EXPIRED / EMBED_TOKEN_INVALID / EMBED_UNAVAILABLECycle de vie du jeton — voir ci-dessus.

Tout ce à quoi vous pourriez voir une référence par ailleurs est interne. En particulier, un ensemble de messages n'est explicitement jamais transmis à un hôte tiers — VIZ_DATA, FILES, UPDATE_TOKEN sortant, SQL_ERROR, CONSOLE_ERROR, REQUEST_NEW_AUTH_TOKEN, ELEMENT_SELECTED, FILTER_STATE_CHANGED — car ils portent soit la source de votre tableau de bord, soit son état de requête brut.

La frontière du cadre​

Deux propriétés décident de ce sur quoi une intégration peut se construire, et les deux méritent d'être lues avant de concevoir autour du cadre plutôt qu'après.

Le cadre possède son propre état de filtrage. Les messages que reçoit un hôte tiers sont ceux listés plus haut ; l'état de filtrage du tableau de bord n'en fait pas partie, dans un sens comme dans l'autre. Le cadre est une vue autonome : pilotez-le par l'URL et le jeton que vous émettez, et laissez-le posséder ce qui se passe à l'intérieur. Si votre coquille doit partager une sélection avec le tableau de bord, mettez cette sélection dans le prédicat de filtrage du jeton, que la plateforme ajoute à chaque requête qu'elle autorise — côté serveur, et avec la même réponse pour chaque tuile.

Choisissez l'audience en gardant le cadrage en tête. Un tableau de bord publié en public est une URL publique, avec tout ce qui en découle ; la voie à jeton signé est celle à utiliser quand il importe de savoir qui regarde et depuis où, et la liste de domaines autorisés d'une clé d'API est ce qui lie ses réponses à votre origine.

La voie de requête à jeton signé tourne en eu-central-1. C'est là que vit le moteur de requête des intégrations protégées. Si les données de votre compte sont dans une autre région, parlez-nous avant de construire sur cette voie.

Prochaines étapes​

  • Qui peut voir quoi — où s'insère le filtrage au niveau des lignes dans le modèle de permissions plus large, et pourquoi il n'existe qu'ici.
  • Publier et partager — amener un tableau de bord à published et choisir son view_mode.
  • Référence API — l'opération embed-tokens en entier.