Yes, it is possible to grant a user the ability to execute a specific function without granting them the ability to create roles directly. To do this, you can define the function with a SECURITY DEFINER
attribute. This attribute causes the function to be executed with the privileges of the user who defined the function, rather than the privileges of the user who is calling the function.
Here is an example of how you could define a function with the SECURITY DEFINER
attribute:
CREATE OR REPLACE FUNCTION public.create_role(role_name text)
RETURNS void AS $$
BEGIN
EXECUTE format('CREATE ROLE %I NOLOGIN', role_name);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
This function takes a role_name
parameter and uses dynamic SQL with the EXECUTE
statement to create a new role with the specified name. The SECURITY DEFINER
attribute is specified at the end of the CREATE FUNCTION
statement.
Once you have defined the function, you can grant the web_anon
user the ability to execute it using the GRANT
statement like this:
GRANT EXECUTE ON FUNCTION public.create_role(text) TO web_anon;
This grants the web_anon
user the ability to execute the public.create_role(text)
function. When this user calls the function, it will be executed with the privileges of the user who defined the function, allowing it to create a new role even if the web_anon
user does not have the CREATEROLE
privilege.
It is important to note that using a SECURITY DEFINER
function can be a security risk if not used carefully. You should make sure that the function only performs actions that are safe for any user to perform and that it properly validates its input to prevent SQL injection attacks.
For the postgREST example, something like this would do for onboarding users (create and own function as admin):
CREATE OR REPLACE FUNCTION api.onboard(id_token text)
RETURNS json AS $$
DECLARE
CLAIMS json := auth.decode_id_token(id_token)::json;
ROLE_NAME text := CLAIMS->>'preferred_username';
BEGIN
EXECUTE format('CREATE ROLE %I NOLOGIN', ROLE_NAME);
EXECUTE format('GRANT %I TO authenticator', ROLE_NAME);
EXECUTE format('GRANT USAGE ON SCHEMA auth TO %I', ROLE_NAME);
EXECUTE format('GRANT USAGE ON SCHEMA api TO %I', ROLE_NAME);
EXECUTE format('ALTER DEFAULT PRIVILEGES FOR ROLE admin IN SCHEMA api GRANT SELECT,USAGE ON SEQUENCES TO %I', ROLE_NAME);
EXECUTE format('ALTER DEFAULT PRIVILEGES FOR ROLE admin IN SCHEMA api GRANT SELECT,INSERT,DELETE,UPDATE ON TABLES TO %I', ROLE_NAME);
EXECUTE format('GRANT SELECT,INSERT,DELETE,UPDATE ON ALL TABLES IN SCHEMA api TO %I', ROLE_NAME);
EXECUTE format('GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA api TO %I', ROLE_NAME);
RETURN format('{
"status": "success",
"message": "User successfully onboarded",
"user": {
"oid": "%s",
"name": "%s",
"preferred_username": "%s"
}
}', CLAIMS->>'oid', CLAIMS->>'name', ROLE_NAME)::json;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
ALTER FUNCTION api.onboard(id_token text) OWNER TO admin;
GRANT EXECUTE ON FUNCTION api.onboard(id_token text) TO web_anon;
And the auth.decode_id_token
function might look like this (there is probably a way to do it without PL/Python3u, which needs the superuser privileges to create the function); note that it is important that the client ID and tenant ID are validated here:
CREATE EXTENSION plpython3u;
CREATE SCHEMA auth;
ALTER SCHEMA auth OWNER TO admin;
CREATE OR REPLACE FUNCTION auth.decode_id_token(id_token text)
RETURNS json
AS $$
import asyncio
import json
from guardpost.jwts import JWTValidator
settings = plpy.execute("SELECT current_setting('app.settings.AZURE_TENANT_ID') AS tid, current_setting('app.settings.AZURE_CLIENT_ID') AS cid")
tenant_id = settings[0]["tid"]
client_id = settings[0]["cid"]
async def main():
validator = JWTValidator(
authority=f"https://login.microsoftonline.com/{tenant_id}/",
valid_issuers=[f"https://login.microsoftonline.com/{tenant_id}/v2.0"],
valid_audiences=[client_id]
)
return await validator.validate_jwt(id_token)
return json.dumps(asyncio.run(main()))
$$ LANGUAGE plpython3u;
ALTER FUNCTION auth.decode_id_token(id_token text) OWNER TO admin;