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 publique | Voie à jeton signé | |
|---|---|---|
view_mode du tableau de bord | public | protected |
| Qui peut voir | n'importe qui avec l'URL | seulement un visionneur pour lequel votre backend a généré un jeton |
| Filtrage de ligne par visionneur | non | oui, appliqué côté serveur |
| Ce que vous intégrez | une URL | une 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 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,
});
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 :
| Champ | Requis | Notes |
|---|---|---|
vizId | oui | Exactement un tableau de bord. Un jeton ne peut pas couvrir un dossier ou un compte. |
filters | non | Les filtres de ligne. Voir ci-dessous. |
viewerId | non | Un 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. |
ttlSeconds | non | Dé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. |
apiKeyId | non | Seulement 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é :
| Statut | Détail | Correctif |
|---|---|---|
403 | la visualisation appartient à un autre compte | générez avec une clé API du compte qui la détient |
403 | la visualisation n'est pas intégrable avec un jeton : sa visibilité doit être protected | passez la visibilité du tableau de bord sur Protégé dans la boîte de dialogue Partager |
404 | la visualisation n'est pas publiée | publiez-la d'abord |
403 | aucune clé API utilisable pour lier le jeton d'intégration | la clé est absente, inactive, expirée ou appartient à un autre compte |
404 | Viz introuvable | vé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
scopede 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. ..."
}
}
| Champ | Quoi en faire |
|---|---|
fullyScoped | Celui 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[].predicate | Le 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. |
unresolvedDatasetIds | Les 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 :
- Le cadre envoie
EMBED_TOKEN_EXPIRED(ouEMBED_TOKEN_INVALID) à votre page. - Votre page demande un nouveau jeton à votre backend.
- Votre page envoie
UPDATE_TOKENdans 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 :
| Message | Quand |
|---|---|
READY | Le moteur de rendu a démarré. |
LOADED | Le tableau de bord a fini de charger. |
RESIZE | La hauteur du contenu a changé — utilisez-le pour dimensionner l'iframe. |
BUNDLE_STARTED / BUNDLE_COMPLETED / BUNDLE_FAILED / BUNDLE_RETRY | Cycle de vie du build du code du tableau de bord. |
RUNTIME_ERROR | Le code du tableau de bord a levé une erreur. |
EMPTY_DATA | Une requête n'a renvoyé aucune ligne. |
RENDER_HEALTH | Santé de rendu par tuile. |
EMBED_TOKEN_EXPIRED / EMBED_TOKEN_INVALID / EMBED_UNAVAILABLE | Cycle 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 à
publishedet choisir sonview_mode. - Référence API — l'opération
embed-tokensen entier.