feat: JWT cache implementation based on sieve algorithm (#4084)

Changes:

1. Refactoring and some cleanup of JWT handling code:
* Instead of caching AuthResult cache decoded claims (which signature was verified). Validating claims and determining role is done after cache lookup
* Cleaned up API so that usage of it is simplified: lookupJwtCache cache key >>= parseClaims configJwtAud time
* Handling of JwtCacheState initialization and updates of configuration is encapsulated in Auth.JwtCache module

2. Generic high performance (hopefully) scalable, dynamically resizeable cache implementation based on stm, stm-hamt and sieve algorithm. It also integrates with PostgREST measurements infrastructure providing usage stats (ie. hit ratio, evictions count)
This commit is contained in:
Michal Kleczek
2025-07-29 18:51:41 -05:00
committed by GitHub
parent ac155a9391
commit 77ff11de95
35 changed files with 664 additions and 203 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"aliased\""
jwt-secret = ""
jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
+1 -1
View File
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ == \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ != \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ ^== \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ ==^ \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ *== \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
@@ -23,7 +23,7 @@ jwt-aud = "https://otherexample.org"
jwt-role-claim-key = ".\"other\".\"pre_config_role\""
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 7200
jwt-cache-max-entries = 86400
log-level = "info"
log-query = "main-query"
openapi-mode = "disabled"
@@ -23,7 +23,7 @@ jwt-aud = "https://example.org"
jwt-role-claim-key = ".\"a\".\"role\""
jwt-secret = "OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE"
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 3600
jwt-cache-max-entries = 86400
log-level = "info"
log-query = "main-query"
openapi-mode = "ignore-privileges"
+1 -1
View File
@@ -23,7 +23,7 @@ jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ="
jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 86400
jwt-cache-max-entries = 86400
log-level = "info"
log-query = "main-query"
openapi-mode = "ignore-privileges"
+1 -1
View File
@@ -23,7 +23,7 @@ jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
jwt-cache-max-entries = 1000
log-level = "error"
log-query = "disabled"
openapi-mode = "follow-privileges"
+1 -1
View File
@@ -26,7 +26,7 @@ PGRST_JWT_AUD: 'https://postgrest.org'
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ=
PGRST_JWT_SECRET_IS_BASE64: true
PGRST_JWT_CACHE_MAX_LIFETIME: 86400
PGRST_JWT_CACHE_MAX_ENTRIES: 86400
PGRST_LOG_LEVEL: info
PGRST_LOG_QUERY: 'main-query'
PGRST_OPENAPI_MODE: 'ignore-privileges'
+1 -1
View File
@@ -23,7 +23,7 @@ jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".user[0].\"real-role\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ="
jwt-secret-is-base64 = true
jwt-cache-max-lifetime = 86400
jwt-cache-max-entries = 86400
log-level = "info"
log-query = "main-query"
openapi-mode = "ignore-privileges"
+2 -2
View File
@@ -14,7 +14,7 @@ ALTER ROLE db_config_authenticator SET pgrst.db_root_spec = 'root';
ALTER ROLE db_config_authenticator SET pgrst.db_schemas = 'test, tenant1, tenant2';
ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override';
ALTER ROLE db_config_authenticator SET pgrst.jwt_aud = 'https://example.org';
ALTER ROLE db_config_authenticator SET pgrst.jwt_cache_max_lifetime = '3600';
ALTER ROLE db_config_authenticator SET pgrst.jwt_cache_max_entries = '86400';
ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"';
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'false';
@@ -68,7 +68,7 @@ ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, othe
ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org';
ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE';
ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'false';
ALTER ROLE other_authenticator SET pgrst.jwt_cache_max_lifetime = '7200';
ALTER ROLE other_authenticator SET pgrst.jwt_cache_max_entries = '86400';
ALTER ROLE other_authenticator SET pgrst.openapi_mode = 'disabled';
ALTER ROLE other_authenticator SET pgrst.openapi_security_active = 'false';
ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api';
+11 -11
View File
@@ -152,7 +152,7 @@ def test_jwt_errors(defaultenv):
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
}
@@ -165,7 +165,7 @@ def test_jwt_errors(defaultenv):
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "false",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
}
@@ -1446,7 +1446,7 @@ def test_jwt_cache_server_timing(defaultenv):
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
@@ -1482,7 +1482,7 @@ def test_jwt_cache_without_server_timing(defaultenv):
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "false",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
@@ -1503,7 +1503,7 @@ def test_jwt_cache_without_exp_claim(defaultenv):
env = {
**defaultenv,
"PGRST_SERVER_TIMING_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
@@ -1772,7 +1772,7 @@ def test_jwt_cache_purges_expired_entries(defaultenv):
env = {
**defaultenv,
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
"PGRST_JWT_SECRET": SECRET,
"PGRST_DB_CONFIG": "false",
}
@@ -1838,10 +1838,10 @@ def test_log_pool_req_observation(level, defaultenv):
postgrest.session.get("/authors_only", headers=headers)
if level == "debug":
output = postgrest.read_stdout(nlines=4)
assert pool_req in output[0]
assert pool_req_fullfill in output[3]
assert len(output) == 4
output = postgrest.read_stdout(nlines=5)
assert pool_req in output[1]
assert pool_req_fullfill in output[4]
assert len(output) == 5
elif level == "info":
output = postgrest.read_stdout(nlines=4)
assert len(output) == 1
@@ -1882,7 +1882,7 @@ def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv):
**defaultenv,
"PGRST_JWT_SECRET": f"@{external_secret_file}",
"PGRST_DB_CHANNEL_ENABLED": "true",
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400", # enable cache
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
}