Merk
Tilgang til denne siden krever autorisasjon. Du kan prøve å logge på eller endre kataloger.
Tilgang til denne siden krever autorisasjon. Du kan prøve å endre kataloger.
Denne artikkelen beskriver hvordan tredjepartsutviklere kan integrere med OneLake-sikkerhet for å spørre data fra OneLake samtidig som de håndhever radnivåsikkerhet (RLS) og kolonnenivåsikkerhet (CLS). Integrasjonen bruker den autoriserte motormodellen, hvor motoren din leser data direkte fra OneLake og håndhever sikkerhetspolicyer i sitt eget beregningslag.
Bemerkning
Denne funksjonen er en del av en forhåndsvisningsutgivelse og tilbys kun for evaluering og utvikling. Det kan endres basert på tilbakemelding og anbefales ikke for produksjonsbruk.
Oversikt
OneLake-sikkerhet definerer finmaskede tilgangskontrollpolicyer – inkludert tabellnivå, radnivå og kolonnenivå sikkerhet – når de er i OneLake. Microsoft Fabric-motorer som Spark og SQL-analyseendepunktet håndhever disse retningslinjene ved spørringstidspunktet. Likevel garanterer OneLake-sikkerhet håndheving av detaljerte tilgangskontrollpolicyer uavhengig av hvordan dataene brukes. Som et resultat blokkeres uautoriserte eksterne forespørsler om å lese filer fra OneLake for å sikre at data ikke lekker.
Den autoriserte motormodellen løser dette problemet. Du registrerer en dedikert identitet (tjenesteprincipal eller administrert identitet) som har full lesetilgang til dataene og også kan lese sikkerhetsmetadataene. Motoren din bruker denne identiteten til å:
- Les rådatafilene fra OneLake.
- Hent de effektive sikkerhetspolicyene for en gitt bruker ved å kalle Get autorisert tilgang for et hoved-API .
- Bruk de returnerte rad- og kolonnefiltrene i sitt eget spørringsutførelseslag.
- Returner kun de tillatte dataene til sluttbrukeren.
Denne tilnærmingen gir motoren full kontroll over planlegging og caching av spørringer, samtidig som sikkerhetshåndhevelsen holdes i tråd med det Fabric-motorer tilbyr, og kontrollen over autorisasjon overlates til brukeren.
Forutsetninger
Før du begynner å integrere, sørg for at du har følgende:
- En Microsoft Entra-tjenesteprincipal eller administrert identitet som motoren din bruker for å få tilgang til OneLake. Kun Microsoft Entra-identiteter støttes.
- Workspace Member (eller høyere) rolle for motoridentiteten i målarbeidsområdet. Dette gir identiteten nødvendige rettigheter til å lese datafiler og sikkerhetsmetadata fra OneLake.
- Et Fabric-element (lakehouse, speilet database eller speilet katalog) med OneLake-sikkerhet aktivert.
- OneLake-sikkerhetsroller er konfigurert på varen med eventuelle RLS- eller CLS-policyer du ønsker å håndheve.
- Motoridentiteten må ha ubegrenset lesetilgang til tabellene den leser. Hvis RLS- eller CLS-policyer gjelder for selve motoridentiteten, returnerer API-kall feil.
Arkitektur
Følgende diagram viser den overordnede autorisasjonsflyten for en autorisert motorintegrasjon.
┌──────────────┐ ┌──────────────────┐ ┌───────────┐
│ End user │──1──▶│ 3rd-party engine │──2──▶│ OneLake │
│ (query) │ │(service principal)│◀──3──│ (data + │
│ │◀──6──│ │──4──▶│ security)│
└──────────────┘ └──────────────────┘ └───────────┘
- Sluttbrukeren sender inn en forespørsel til tredjepartsmotoren.
- Motoridentiteten autentiseres til OneLake og leser rådatafilene (Delta parquet) ved hjelp av OneLake-API-er.
- OneLake returnerer de forespurte dataene.
- Motoren kaller API-et
principalAccess, og sender sluttbrukerens Microsoft Entra-objekt-ID, for å få brukerens effektive tilgang. - Motoren anvender de returnerte tilgangsfiltrene (tabelltilgang, RLS-predikater, CLS-kolonnelister) på dataene i sitt eget beregningslag.
- Motoren returnerer kun de filtrerte, tillatte resultatene til sluttbrukeren.
Trinn 1: Sett opp motoridentiteten
Motoren din trenger en Microsoft Entra-identitet som OneLake gjenkjenner og stoler på. Denne identiteten leser datafiler og sikkerhetsmetadata på vegne av motoren din.
Opprett eller identifiser en tjenesteprincipal eller administrert identitet i Microsoft Entra ID for motoren din. Hvis du vil ha mer informasjon, kan du se Program- og tjenestekontohaverobjekter i Microsoft Entra ID.
Legg til identiteten i rollen som arbeidsplassmedlem. I Fabric-portalen, gå til arbeidsområdets innstillinger og legg til tjenesteansvarlig i medlemrollen . Dette gir identiteten:
- Les tilgang til alle datafiler i OneLake for elementer i det arbeidsområdet.
- Tilgang til å lese OneLake sikkerhetsrollemetadata gjennom de autoriserte motor-API-ene.
For mer informasjon om arbeidsplasser, se Roller i arbeidsområder.
Sørg for at identiteten har ubegrenset tilgang. Motoridentiteten må ha full lesetilgang til hver tabell den spør om. Hvis noen OneLake-sikkerhetsrolle anvender RLS- eller CLS-restriksjoner på motoridentiteten, feiler datalesing og API-kall. Beste praksis er å ikke legge til motoridentiteten i noen OneLake-sikkerhetsroller som inneholder RLS- eller CLS-begrensninger.
Viktig!
Du kan trekke tilbake motorens tilgang når som helst ved å fjerne den fra arbeidsplassrollen. Tilbakekalling av tilgang trer i kraft innen omtrent 2 minutter.
Trinn 2: Les data fra OneLake
Med motoridentiteten konfigurert kan motoren din lese datafiler direkte fra OneLake ved hjelp av standard Azure Data Lake Storage (ADLS) Gen2-kompatible API-er.
OneLake-data er tilgjengelig på:
https://onelake.dfs.fabric.microsoft.com/{workspaceId}/{itemId}/Tables/{schema}/{tableName}/
Motoren din autentiseres ved hjelp av en bærertoken hentet gjennom Microsoft Entra OAuth 2.0 klientloggflyten. Bruk OneLake-ressursomfanget https://storage.azure.com/.default når du ber om tokenet.
Eksempel: Autentisere og lese data (Python)
from azure.identity import ClientSecretCredential
from azure.storage.filedatalake import DataLakeServiceClient
tenant_id = "<your-tenant-id>"
client_id = "<your-service-principal-client-id>"
client_secret = "<your-service-principal-secret>"
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
service_client = DataLakeServiceClient(
account_url="https://onelake.dfs.fabric.microsoft.com",
credential=credential
)
# Access a specific item in a workspace
file_system_client = service_client.get_file_system_client("<workspace-id>")
directory_client = file_system_client.get_directory_client("<item-id>/Tables/dbo/Customers")
# List and read Delta parquet files
for path in directory_client.get_paths():
if path.name.endswith(".parquet"):
file_client = file_system_client.get_file_client(path.name)
downloaded = file_client.download_file()
data = downloaded.readall()
# Process the parquet data with your engine
For mer informasjon om OneLake-API-er, se OneLake-tilgang med API-er.
Trinn 3: Hent brukerens effektive tilgang
Etter å ha lest rådataene må motoren din avgjøre hva brukeren som søker kan se. Kall Get autorisert tilgang for et hoved-API for å få brukerens effektive tilgang til varen.
API-endepunkt
GET https://onelake.dfs.fabric.microsoft.com/v1.0/workspaces/{workspaceId}/artifacts/{artifactId}/securityPolicy/principalAccess
Forespørselstekst
{
"aadObjectId": "<end-user-entra-object-id>",
"inputPath": "Tables",
"maxResults": 500 //optional, default is 500
}
| Parameter | Type | Påkrevd | Beskrivelse |
|---|---|---|---|
| aadObjectId | streng | Ja | Microsoft Entra-objekt-ID-en til sluttbrukeren hvis tilgang du vil sjekke. |
| inputPath | streng | Ja | Enten Tables eller Files. Returnerer brukerens tilgang til den angitte delen av elementet. For de fleste spørringsmotorer vil inputPath være Tables. |
| fortsettelseToken | streng | Nei | Brukes til å hente fortsatte resultater når resultatsettet overstiger maxResults. |
| maxResults | heltall | Nei | Maksimalt antall elementer per side. Standard er 500. |
Prøverespons (kun RLS)
{
"identityETag": "3fc4dc476ded773e4cf43936190bf20fa9480a077b25edc0b4bbe247112542f6",
"metadataETag": "\"eyJhciI6IlwiMHg4R...\"",
"value": [
{
"path": "Tables/dbo/Customers",
"access": ["Read"],
"rows": "SELECT * FROM [dbo].[Customers] WHERE [customerId] = '123'",
"effect": "Permit"
},
{
"path": "Tables/dbo/Employees",
"access": ["Read"],
"rows": "SELECT * FROM [dbo].[Employees] WHERE [address] = '123'",
"effect": "Permit"
},
{
"path": "Tables/dbo/EmployeeTerritories",
"access": ["Read"],
"effect": "Permit"
}
]
}
Prøverespons (RLS og CLS)
Når sikkerhet på kolonnenivå konfigureres i en tabell, inkluderer svaret et columns array som kun viser kolonnene brukeren har tilgang til. Kolonner som ikke finnes i dette arrayet er skjult for brukeren.
{
"identityETag": "79372bc169b00882d9abec3d404032131e96bc406e15c6766514723021e153eb",
"metadataETag": "\"eyJhciI6IlwiMHg4R...\"",
"value": [
{
"path": "Tables/dbo/Customers",
"access": ["Read"],
"columns": [
{
"name": "address",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "city",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "contactTitle",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "country",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "fax",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "phone",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "postalCode",
"columnEffect": "Permit",
"columnAction": ["Read"]
},
{
"name": "region",
"columnEffect": "Permit",
"columnAction": ["Read"]
}
],
"rows": "SELECT * FROM [dbo].[Customers] WHERE [customerID] = 'ALFKI'",
"effect": "Permit"
},
{
"path": "Tables/dbo/Employees",
"access": ["Read"],
"rows": "SELECT * FROM [dbo].[Employees] WHERE [address] = '123'",
"effect": "Permit"
}
]
}
Å forstå responsen
Svaret inneholder et array av PrincipalAccessEntry objekter, som hver representerer en tabell brukeren har tilgang til. Tabeller som ikke er med i svaret er ikke tilgjengelige for brukeren.
| Felt | Type | Beskrivelse |
|---|---|---|
path |
streng | Veien til tabellen brukeren kan få tilgang til, for eksempel Tables/dbo/Customers. |
access |
streng[] | Utvalget av tilgangstyper gitt. For øyeblikket støttes det kun Read . |
columns |
Objekt[] | Et array av kolonneobjekter brukeren har tilgang til. Hvert objekt inneholder name (kolonnenavn), columnEffect (Permit), og columnAction (["Read"]). Hvis dette feltet mangler, gjelder ingen CLS og alle kolonner er tillatt. Hvis det er til stede, skal kun de oppførte kolonnene returneres. |
rows |
streng | En T-SQL-setning SELECT som representerer sikkerhetsfilteret på radnivå. Kun rader som matcher dette predikatet skal returneres til brukeren. Hvis dette feltet mangler, gjelder ingen RLS og alle rader er tillatt. |
effect |
streng | Effekttypen. For øyeblikket alltid Permit. |
Viktig!
Feltet rows inneholder et T-SQL-uttrykk som motoren din må parse og bruke som et filterpredikat. Uttrykket bruker et SELECT * FROM [schema].[table] WHERE ... format. Motoren din må hente ut klausulen WHERE og anvende den på dataene som returneres.
ETags for caching
Responsen inkluderer to ETag-verdier som muliggjør effektiv caching:
-
identityETag: Representerer den nåværende tilstanden til brukerens identitet og gruppemedlemskap. Cache brukerens tilgangsresultat og bruk det på nytt til denne ETag endres. -
metadataETag: Representerer den nåværende tilstanden til varens sikkerhetskonfigurasjon. Cache rollemetadata og gjenbruk det til denne ETag endres.
Bruk disse ETag-ene sammen med forespørselsheaderen If-None-Match for å unngå å hente uendrede data på nytt. Dette forbedrer ytelsen for fler-bruker-cacher.
Eksempel: Hent effektiv tilgang (Python)
import requests
# Get a token for the OneLake DFS endpoint
token = credential.get_token("https://storage.azure.com/.default").token
workspace_id = "<workspace-id>"
artifact_id = "<artifact-id>"
user_object_id = "<end-user-entra-object-id>"
url = (
f"https://onelake.dfs.fabric.microsoft.com/v1.0/"
f"workspaces/{workspace_id}/artifacts/{artifact_id}/"
f"securityPolicy/principalAccess"
)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
body = {
"aadObjectId": user_object_id,
"inputPath": "Tables"
}
response = requests.get(url, headers=headers, json=body)
access_data = response.json()
# The response contains the user's effective access
for entry in access_data["value"]:
print(f"Table: {entry['path']}, Access: {entry['access']}")
if "columns" in entry:
col_names = [col["name"] for col in entry["columns"]]
print(f" CLS permitted columns: {col_names}")
if "rows" in entry:
print(f" RLS filter: {entry['rows']}")
Trinn 4: Bruk sikkerhetsfiltre
Etter å ha hentet brukerens effektive tilgang, må motoren din anvende sikkerhetspolicyene på dataene før den returnerer resultater. Dette steget er avgjørende – motoren din er ansvarlig for å håndheve retningslinjene korrekt.
Tabellnivåfiltrering
Returner kun data fra tabeller som vises i svaret principalAccess . Hvis en tabell ikke er oppført, har brukeren ingen tilgang til den, og ingen data skal returneres.
# Build a set of accessible tables for the user
accessible_tables = {entry["path"] for entry in access_data["value"]}
# Before returning query results, verify the table is accessible
def is_table_accessible(table_path: str) -> bool:
return table_path in accessible_tables
Sikkerhetsfiltrering på radnivå
Når et rows felt er til stede i en tilgangsoppføring, må motoren din analysere T-SQL-predikatet og bruke det som et filter på tabellens data. Verdien rows er en SELECT setning med en WHERE klausul som definerer hvilke rader brukeren kan se.
Viktig!
Hvis motoren din ikke kan tolke SQL-setninger, bør spørringer mot tabeller med en ikke-null-egenskap rows feile med en feil og ikke returnere noen data. Dette sikrer at brukerne kun får tilgang til det de har lov til å se.
For eksempel, følgende RLS-filter:
SELECT * FROM [dbo].[Customers] WHERE [customerId] = '123' UNION SELECT * FROM [dbo].[Customers] WHERE [customerID] = 'ALFKI'
Motoren din bør hente ut predikatene og bruke dem for å filtrere dataene:
import sqlparse
def extract_rls_predicates(rls_expression: str) -> list:
"""
Parse the RLS T-SQL expression and extract WHERE clause predicates.
The expression may contain UNION of multiple SELECT statements.
"""
predicates = []
statements = rls_expression.split(" UNION ")
for stmt in statements:
parsed = sqlparse.parse(stmt)[0]
where_seen = False
where_clause = []
for token in parsed.tokens:
if where_seen:
where_clause.append(str(token).strip())
if token.ttype is sqlparse.tokens.Keyword and token.value.upper() == "WHERE":
where_seen = True
if where_clause:
predicates.append(" ".join(where_clause))
return predicates
def apply_rls_filter(dataframe, access_entry: dict):
"""Apply RLS filtering to a dataframe based on the access entry."""
if "rows" not in access_entry:
return dataframe # No RLS, return all rows
predicates = extract_rls_predicates(access_entry["rows"])
# Combine predicates with OR (UNION semantic)
combined_filter = " OR ".join(f"({p})" for p in predicates)
return dataframe.filter(combined_filter)
Viktig!
Når rows feltet mangler i en tilgangsoppføring, gjelder ingen RLS for den tabellen, og alle rader skal returneres. Når feltet er til stede, må motoren filtrere dataene. Å returnere ufiltrert data for en tabell med RLS er et sikkerhetsbrudd.
Sikkerhetsfiltrering på kolonnenivå
Når CLS konfigureres på en tabell, inkluderer svaret principalAccess et columns array som eksplisitt lister kolonnene brukeren har tilgang til. Hvert kolonneobjekt inneholder:
| Eiendom | Type | Beskrivelse |
|---|---|---|
name |
streng | Kolonnenavnet (kasusfølsomt). |
columnEffect |
streng | Effekten gjelder for kolonnen. For øyeblikket alltid Permit. |
columnAction |
streng[] | Handlingene tillatt på kolonnen. For øyeblikket støttes det kun Read . |
Hvis columnsfeltet mangler i en tilgangsoppføring, gjelder ingen CLS og alle kolonner i tabellen er tillatt. Hvis columns feltet er til stede, må motoren returnere kun de oppgitte kolonnene.
def get_permitted_columns(access_entry: dict) -> list | None:
"""
Return the list of permitted column names for a table.
Returns None if no CLS applies (all columns are permitted).
"""
if "columns" not in access_entry:
return None # No CLS, all columns are permitted
return [
col["name"]
for col in access_entry["columns"]
if col.get("columnEffect") == "Permit"
and "Read" in col.get("columnAction", [])
]
def apply_cls_filter(dataframe, access_entry: dict):
"""Apply CLS filtering to a dataframe based on the access entry."""
permitted_columns = get_permitted_columns(access_entry)
if permitted_columns is None:
return dataframe # No CLS, return all columns
# Only keep columns that are in the permitted list
return dataframe.select(permitted_columns)
Viktig!
Når columns feltet mangler i en tilgangsoppføring, gjelder ingen CLS og alle kolonner skal returneres. Når feltet er til stede, må motoren kun returnere de oppgitte kolonnene. Å returnere skjulte kolonner er et sikkerhetsbrudd.
Håndtering av tabeller uten tilgang
Hvis en bruker spør i en tabell som ikke vises i svaret principalAccess , må motoren din nekte tilgang. Ikke fall tilbake på å returnere ufiltrerte data.
def query_table(table_path: str, user_access: dict):
"""Query a table with OneLake security enforcement."""
# Find the user's access entry for this table
entry = next(
(e for e in user_access["value"] if e["path"] == table_path),
None
)
if entry is None:
raise PermissionError(
f"Access denied: user doesn't have permission to access {table_path}"
)
# Read the data from OneLake
data = read_table_from_onelake(table_path)
# Apply column-level security
data = apply_cls_filter(data, entry)
# Apply row-level security
data = apply_rls_filter(data, entry)
return data
Trinn 5: Håndter caching og endringsdeteksjon
For integrasjoner på produksjonsnivå, spesielt motorer med flerbruker datacacher, må du håndtere endringer i sikkerhetspolicyer og medlemskap i brukergrupper.
Cache-sikkerhetsmetadata
Bruk og-verdiene identityETagmetadataETag fra svaret principalAccess for å avgjøre når bufret sikkerhetsinformasjon er utdatert:
-
identityETag: Endres når brukerens gruppemedlemskap eller identitetsegenskaper oppdateres. Cache brukerens effektive tilgang tastet på(userId, identityETag). -
metadataETag: Endres når OneLake-sikkerhetsrollene eller -policyene på varen oppdateres. Cache-rolledefinisjoner basert på(artifactId, metadataETag).
Meningsmålinger for endringer
Sjekk API-et principalAccess jevnlig for å oppdage endringer. API-et bør polles før spørringskjøring for å sikre at ingenting har endret seg, i stedet for å levere resultater direkte fra cachen. Bruk If-None-Match headeren med det tidligere mottatte ETag for å minimere båndbredden:
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"If-None-Match": f'"{cached_etag}"'
}
response = requests.get(url, headers=headers, json=body)
if response.status_code == 304:
# Security hasn't changed, use cached data
pass
elif response.status_code == 200:
# Security has changed, update cache
new_access_data = response.json()
update_cache(user_id, new_access_data)
Ventetid
- Endringer i definisjonene av OneLake-sikkerhetsroller tar omtrent 5 minutter å spre seg.
- Endringer i brukergruppemedlemskap i Microsoft Entra ID tar omtrent 1 time å reflekteres i OneLake.
- Noen tekstilmotorer har sitt eget caching-lag, så det kan kreve ekstra tid.
Design pollingintervallet ditt og cache TTL deretter. En anbefalt tilnærming er å spørre hvert 5. minutt for endringer i sikkerhetsmetadata og oppdatere brukerspesifikk tilgang på hver spørring eller med kortere mellomrom.
Trinn 6: Håndter paginering
API-et principalAccess støtter paginering for elementer med mange tabeller. Når svaret inneholder flere elementer enn maxResults, inneholder svaret en continuationToken.
all_entries = []
continuation_token = None
while True:
body = {
"aadObjectId": user_object_id,
"inputPath": "Tables",
"maxResults": 500
}
if continuation_token:
body["continuationToken"] = continuation_token
response = requests.get(url, headers=headers, json=body)
data = response.json()
all_entries.extend(data["value"])
# Check for continuation token in response
continuation_token = data.get("continuationToken")
if not continuation_token:
break
Feilbehandling
Håndter følgende feilscenarier i integrasjonen din:
| HTTP-status | Feilkode | Beskrivelse | Anbefalt handling |
|---|---|---|---|
| 200 | - | Success. | Bearbeide svaret. |
| 404 | ItemNotFound | Arbeidsområdet eller gjenstanden eksisterer ikke, eller motoridentiteten har ikke tilgang. | Verifiser arbeidsområdets ID og artefakt-ID. Bekreft at motoridentiteten har tilgang til arbeidsplassmedlem. |
| 412 | PreconditionFailed | Den oppgitte ETag-en stemmer If-Match ikke overens med den nåværende ressursen ETag. |
Hente ressursen på nytt uten If-Match headeren for å få den nyeste ETag. |
| 429 | - | Hastighetsgrense overskredet. | Vent til varigheten som er spesifisert i Retry-After headeren før du prøver på nytt. |
Anbefalte fremgangsmåter for sikkerhet
Følg disse beste praksisene for å sikre en sikker integrasjon:
- Beskytt motorens identitetsopplysninger. Tjenestelederen har økt tilgang til data i OneLake. Lagre legitimasjon sikkert ved hjelp av tjenester som Azure Key Vault.
- Ikke eksponer rådata for sluttbrukere. Bruk alltid sikkerhetsfiltrene som API-et returnerer
principalAccessfør du returnerer data. Å hoppe over håndhevelse er et sikkerhetsbrudd. - Valider RLS-predikater nøye. Parse og anvende T-SQL-klausulens
WHEREpredikater nøyaktig. Feil parsing kan føre til datalekkasje. Hvis parsingfeil eller usikker syntaksmapping oppstår, feiler spørringen med en RLS-parsingsfeil i stedet for å vise delvise eller usikre resultater til brukeren. - Håndter manglende tabeller som tilgang nektet. Hvis en tabell ikke er til stede i API-svaret, har brukeren ikke tilgang. Aldri gå tilbake til ufiltrerte data, OneLake-sikkerheten bruker alltid avkreft som standard.
- Audit-tilgang. Logg hvilke brukere som får tilgang til hvilke tabeller og hvilke sikkerhetspolicyer som ble brukt for samsvar og feilsøking.
- Avstemning for sikkerhetsendringer. Bruk ETags for å oppdage endringer og oppdatere bufrede policyer raskt.
Begrensninger
- API-et
principalAccesser i forhåndsvisning og kan endres basert på tilbakemeldinger. - Kun tilgangstypen
ReadogPermiteffekten støttes i dag. - Motoridentiteten må ha ubegrenset root-nivå tilgang. Hvis RLS eller CLS gjelder for motoridentiteten, feiler API-kall.
- RLS-predikater bruker T-SQL-syntaks. Motoren din er ansvarlig for å analysere og anvende predikatene korrekt.
- Endringer i sikkerhetspolitikken tar omtrent 5 minutter å spre seg. Endringer i brukergruppemedlemskap tar omtrent 1 time.