sql >> Databasteknik >  >> RDS >> PostgreSQL

Applikationsanvändare kontra Row Level Security

För några dagar sedan har jag bloggat om de vanliga problemen med roller och privilegier som vi upptäcker under säkerhetsgranskningar.

Självklart erbjuder PostgreSQL många avancerade säkerhetsrelaterade funktioner, en av dem är Row Level Security (RLS), tillgänglig sedan PostgreSQL 9.5.

Eftersom 9.5 släpptes i januari 2016 (alltså för bara några månader sedan), är RLS en ganska ny funktion och vi har inte riktigt att göra med många produktionsinstallationer ännu. Istället är RLS ett vanligt ämne för "hur man implementerar" diskussioner, och en av de vanligaste frågorna är hur man får det att fungera med användare på applikationsnivå. Så låt oss se vilka möjliga lösningar som finns.

Introduktion till RLS

Låt oss först se ett mycket enkelt exempel som förklarar vad RLS handlar om. Låt oss säga att vi har en chat tabell som lagrar meddelanden som skickas mellan användare – användarna kan infoga rader i den för att skicka meddelanden till andra användare, och fråga den för att se meddelanden som skickats till dem av andra användare. Så tabellen kan se ut så här:

CREATE TABLE chat (
    message_uuid    UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    message_time    TIMESTAMP NOT NULL DEFAULT now(),
    message_from    NAME      NOT NULL DEFAULT current_user,
    message_to      NAME      NOT NULL,
    message_subject VARCHAR(64) NOT NULL,
    message_body    TEXT
);

Den klassiska rollbaserade säkerheten tillåter oss bara att begränsa åtkomsten till antingen hela tabellen eller vertikala delar av den (kolumner). Så vi kan inte använda det för att hindra användare från att läsa meddelanden avsedda för andra användare, eller skicka meddelanden med ett falskt message_from fältet.

Och det är precis vad RLS är till för – det låter dig skapa regler (policyer) som begränsar åtkomsten till delmängder av rader. Så du kan till exempel göra detta:

CREATE POLICY chat_policy ON chat
    USING ((message_to = current_user) OR (message_from = current_user))
    WITH CHECK (message_from = current_user)

Denna policy säkerställer att en användare endast kan se meddelanden som skickats av honom eller är avsedda för honom – det är vad villkoret i USING klausul gör det. Den andra delen av policyn (WITH CHECK ) försäkrar att en användare endast kan infoga meddelanden med sitt användarnamn i message_from kolumn, förhindrar meddelanden med förfalskade avsändare.

Du kan också föreställa dig RLS som ett automatiskt sätt att lägga till ytterligare WHERE-villkor. Du skulle kunna göra det manuellt på applikationsnivå (och innan RLS gjorde det ofta), men RLS gör det på ett tillförlitligt och säkert sätt (mycket ansträngning lades till exempel på att förhindra olika informationsläckor).

Obs :Före RLS var ett populärt sätt att uppnå något liknande att göra tabellen oåtkomlig direkt (återkalla alla privilegier) och tillhandahålla en uppsättning säkerhetsdefinieringsfunktioner för att komma åt den. Det uppnådde för det mesta samma mål, men funktioner har olika nackdelar - de tenderar att förvirra optimeraren och allvarligt begränsa flexibiliteten (om användaren behöver göra något och det inte finns någon lämplig funktion för det har han otur). Och naturligtvis måste du skriva dessa funktioner.

Appanvändare

Om du läser den officiella dokumentationen om RLS kanske du märker en detalj – alla exempel använder current_user , dvs den aktuella databasanvändaren. Men det är inte så de flesta databasapplikationer fungerar nuförtiden. Webbapplikationer med många registrerade användare upprätthåller inte 1:1-mappning till databasanvändare, utan använder istället en enda databasanvändare för att köra frågor och hantera applikationsanvändare på egen hand – kanske i en users bord.

Tekniskt sett är det inte ett problem att skapa många databasanvändare i PostgreSQL. Databasen borde hantera det utan problem, men applikationer gör det inte av ett antal praktiska skäl. De behöver till exempel spåra ytterligare information för varje användare (t.ex. avdelning, position inom organisationen, kontaktuppgifter, …), så applikationen skulle behöva users bord i alla fall.

En annan anledning kan vara anslutningspoolning – att använda ett enda delat användarkonto, även om vi vet att det går att lösa med arv och SET ROLE (se föregående inlägg).

Men låt oss anta att du inte vill skapa separata databasanvändare – du vill fortsätta använda ett enda delat databaskonto och använda RLS med applikationsanvändare. Hur gör man det?

Sessionsvariabler

Vad vi behöver är i huvudsak att skicka ytterligare sammanhang till databassessionen, så att vi senare kan använda den från säkerhetspolicyn (istället för current_user variabel). Och det enklaste sättet att göra det i PostgreSQL är sessionsvariabler:

SET my.username = 'tomas'

Om detta liknar de vanliga konfigurationsparametrarna (t.ex. SET work_mem = '...' ), du har helt rätt – det är för det mesta samma sak. Kommandot definierar ett nytt namnområde (my ), och lägger till ett username variabel i den. Det nya namnutrymmet krävs, eftersom det globala är reserverat för serverkonfigurationen och vi kan inte lägga till nya variabler till det. Detta gör att vi kan ändra säkerhetspolicyn så här:

CREATE POLICY chat_policy ON chat
    USING (current_setting('my.username') IN (message_from, message_to))
    WITH CHECK (message_from = current_setting('my.username'))

Allt vi behöver göra är att se till att anslutningspoolen/applikationen ställer in användarnamnet när den får en ny anslutning och tilldelar det till användaruppgiften.

Låt mig påpeka att detta tillvägagångssätt kollapsar när du tillåter användarna att köra godtycklig SQL på anslutningen, eller om användaren lyckas upptäcka en lämplig SQL-injektionssårbarhet. I så fall finns det inget som kan hindra dem från att ställa in ett godtyckligt användarnamn. Men misströsta inte, det finns en massa lösningar på det problemet, och vi kommer snabbt att gå igenom dem.

Signerade sessionsvariabler

Den första lösningen är en enkel förbättring av sessionsvariablerna – vi kan inte riktigt hindra användarna från att ställa in godtyckliga värden, men tänk om vi kunde verifiera att värdet inte undergrävdes? Det är ganska enkelt att göra med en enkel digital signatur. Istället för att bara lagra användarnamnet kan den betrodda delen (anslutningspool, applikation) göra något så här:

signature = sha256(username + timestamp + SECRET)

och lagra sedan både värdet och signaturen i sessionsvariabeln:

SET my.username = 'username:timestamp:signature'

Förutsatt att användaren inte känner till SECRET-strängen (t.ex. 128B av slumpmässiga data), borde det inte vara möjligt att ändra värdet utan att ogiltigförklara signaturen.

Obs :Det här är ingen ny idé – det är i princip samma sak som signerade HTTP-cookies. Django har en ganska bra dokumentation om det.

Det enklaste sättet att skydda SECRET-värdet är genom att lagra det i en tabell som inte är tillgänglig för användaren och tillhandahålla en security definer funktion, som kräver ett lösenord (så att användaren inte bara kan signera godtyckliga värden).

CREATE FUNCTION set_username(uname TEXT, pwd TEXT) RETURNS text AS $
DECLARE
    v_key   TEXT;
    v_value TEXT;
BEGIN
    SELECT sign_key INTO v_key FROM secrets;
    v_value := uname || ':' || extract(epoch from now())::int;
    v_value := v_value || ':' || crypt(v_value || ':' || v_key,
                                       gen_salt('bf'));
    PERFORM set_config('my.username', v_value, false);
    RETURN v_value;
END;
$ LANGUAGE plpgsql SECURITY DEFINER STABLE;

Funktionen letar helt enkelt upp signeringsnyckeln (hemlig) i en tabell, beräknar signaturen och ställer sedan in värdet i sessionsvariabeln. Det returnerar också värdet, mest för bekvämlighets skull.

Så den betrodda delen kan göra detta precis innan anslutningen lämnas till användaren (uppenbarligen är "lösenfras" inte ett särskilt bra lösenord för produktion):

SELECT set_username('tomas', 'passphrase')

Och då behöver vi förstås en annan funktion som helt enkelt verifierar signaturen och antingen felar eller returnerar användarnamnet om signaturen matchar.

CREATE FUNCTION get_username() RETURNS text AS $
DECLARE
    v_key   TEXT;
    v_parts TEXT[];
    v_uname TEXT;
    v_value TEXT;
    v_timestamp INT;
    v_signature TEXT;
BEGIN

    -- no password verification this time
    SELECT sign_key INTO v_key FROM secrets;

    v_parts := regexp_split_to_array(current_setting('my.username', true), ':');
    v_uname := v_parts[1];
    v_timestamp := v_parts[2];
    v_signature := v_parts[3];

    v_value := v_uname || ':' || v_timestamp || ':' || v_key;
    IF v_signature = crypt(v_value, v_signature) THEN
        RETURN v_uname;
    END IF;

    RAISE EXCEPTION 'invalid username / timestamp';
END;
$ LANGUAGE plpgsql SECURITY DEFINER STABLE;

Och eftersom den här funktionen inte behöver lösenordsfrasen kan användaren helt enkelt göra detta:

SELECT get_username()

Men get_username() funktion är avsedd för säkerhetspolicyer, t.ex. så här:

CREATE POLICY chat_policy ON chat
    USING (get_username() IN (message_from, message_to))
    WITH CHECK (message_from = get_username())

Ett mer komplett exempel, packat som en enkel tillägg, kan hittas här.

Observera att alla objekt (tabell och funktioner) ägs av en privilegierad användare, inte användaren som har åtkomst till databasen. Användaren har bara EXECUTE privilegium på funktionerna, som dock definieras som SECURITY DEFINER . Det är det som gör att det här schemat fungerar samtidigt som det skyddar hemligheten från användaren. Funktionerna är definierade som STABLE , för att begränsa antalet anrop till crypt() funktion (vilket är avsiktligt dyrt för att förhindra bruteforcing).

Exempelfunktionerna behöver definitivt mer arbete. Men förhoppningsvis är det tillräckligt bra för ett proof of concept som visar hur man lagrar ytterligare sammanhang i en skyddad sessionsvariabel.

Vad behöver fixas undrar du? För det första hanterar funktionerna inte olika feltillstånd särskilt bra. För det andra, även om det signerade värdet inkluderar en tidsstämpel, gör vi egentligen ingenting med det – det kan till exempel användas för att förfalla värdet. Det är möjligt att lägga till ytterligare bitar i värdet, t.ex. en avdelning för användaren, eller till och med information om sessionen (t.ex. PID för backend-processen för att förhindra återanvändning av samma värde på andra anslutningar).

Krypto

De två funktionerna är beroende av kryptografi – vi använder inte mycket förutom några enkla hashfunktioner, men det är fortfarande ett enkelt kryptoschema. Och alla vet att du inte bör göra din egen krypto. Det är därför jag använde tillägget pgcrypto, särskilt crypt() funktion för att komma runt detta problem. Men jag är ingen kryptograf, så även om jag tror att hela schemat är bra, kanske jag missar något – låt mig veta om du ser något.

Signeringen skulle också vara en bra matchning för kryptografi med offentliga nyckel – vi skulle kunna använda en vanlig PGP-nyckel med en lösenordsfras för signeringen och den offentliga delen för signaturverifiering. Tyvärr stöder pgcrypto PGP för kryptering, men det stöder inte signeringen.

Alternativa tillvägagångssätt

Naturligtvis finns det olika alternativa lösningar. Till exempel istället för att lagra signeringshemligheten i en tabell kan du hårdkoda den i funktionen (men då måste du se till att användaren inte kan se källkoden). Eller så kan du göra signeringen i en C-funktion, i vilket fall den är dold för alla som inte har tillgång till minnet (i vilket fall du förlorade ändå).

Dessutom, om du inte alls gillar signeringsmetoden kan du byta ut den signerade variabeln med en mer traditionell "valv"-lösning. Vi behöver ett sätt att lagra data, men vi måste se till att användaren inte kan se eller modifiera innehållet godtyckligt, förutom på ett definierat sätt. Men hallå, det är vad vanliga tabeller med ett API implementerat med security definer funktioner kan göra!

Jag kommer inte att presentera hela det omarbetade exemplet här (kolla detta tillägg för ett komplett exempel), men vad vi behöver är en sessions tabell som fungerar som valv:

CREATE TABLE sessions (
    session_id    UUID PRIMARY KEY,
    session_user  NAME NOT NULL
)

Tabellen får inte vara tillgänglig för vanliga databasanvändare – en enkel REVOKE ALL FROM ... borde ta hand om det. Och så ett API som består av två huvudfunktioner:

  • set_username(user_name, passphrase) – genererar ett slumpmässigt UUID, infogar data i valvet och lagrar UUID i en sessionsvariabel
  • get_username() – läser UUID från en sessionsvariabel och slår upp raden i tabellen (fel om ingen matchande rad)

Detta tillvägagångssätt ersätter signaturskyddet med slumpmässighet för UUID – användaren kan justera sessionsvariabeln, men sannolikheten att träffa ett befintligt ID är försumbar (UUID är 128-bitars slumpmässiga värden).

Det är lite mer traditionellt tillvägagångssätt, som förlitar sig på traditionell rollbaserad säkerhet, men det har också några nackdelar – till exempel gör det faktiskt databasskrivningar, vilket innebär att det är inkompatibelt med hot standby-system.

Bli av med lösenfrasen

Det är också möjligt att designa valvet så att lösenordsfrasen inte är nödvändig. Vi har introducerat det eftersom vi antog set_username händer på samma anslutning – vi måste hålla funktionen körbar (så att bråka med roller eller privilegier är ingen lösning), och lösenfrasen säkerställer att endast den betrodda komponenten faktiskt kan använda den.

Men vad händer om signeringen/sessionsskapandet sker på en separat anslutning, och endast resultatet (signerat värde eller sessions-UUID) kopieras till anslutningen som lämnas till användaren? Tja, då behöver vi inte lösenfrasen längre. (Det är lite likt vad Kerberos gör – att skapa en biljett på en betrodd anslutning och sedan använda biljetten för andra tjänster.)

Sammanfattning

Så låt mig snabbt sammanfatta detta blogginlägg:

  • Medan alla RLS-exemplen använder databasanvändare (med hjälp av current_user ), är det inte särskilt svårt att få RLS att fungera med applikationsanvändare.
  • Sessionsvariabler är en pålitlig och ganska enkel lösning, förutsatt att systemet har en pålitlig komponent som kan ställa in variabeln innan anslutningen lämnas till en användare.
  • När användaren kan köra godtycklig SQL (antingen genom design eller tack vare en sårbarhet), förhindrar en signerad variabel användaren från att ändra värdet.
  • Andra lösningar är möjliga, t.ex. ersätter sessionsvariablerna med tabeller som lagrar information om sessioner identifierade av slumpmässigt UUID.
  • En bra sak är att sessionsvariablerna inte skriver någon databas, så det här tillvägagångssättet kan fungera på skrivskyddade system (t.ex. hot standby).

I nästa del av den här bloggserien kommer vi att titta på att använda applikationsanvändare när systemet inte har en betrodd komponent (så det kan inte ställa in sessionsvariabeln eller skapa en rad i sessions tabell), eller när vi vill utföra (ytterligare) anpassad autentisering i databasen.


  1. En indexerad vybugg med skalära aggregat

  2. SCOPE_IDENTITY() för GUID?

  3. Hur man migrerar databaser och datafiler

  4. Exportera SQLite-databas till XML-fil