diff --git a/README.MD b/README.MD deleted file mode 100644 index e917c35e..00000000 --- a/README.MD +++ /dev/null @@ -1,84 +0,0 @@ -# XMLA - - -## List Of Supported Disover-RequestTypes - - -|RequestTypes|supported|description| -|------------|:-------:|-----------| -|MDSCHEMA_FUNCTIONS|+| | -|MDSCHEMA_DIMENSIONS|+| | -|MDSCHEMA_CUBES|+| | -|MDSCHEMA_ACTIONS|+| | -|DBSCHEMA_TABLES|+| | -|DISCOVER_LITERALS|+| | -|DISCOVER_KEYWORDS|+| | -|DISCOVER_ENUMERATORS|+| | -|DISCOVER_SCHEMA_ROWSETS|+| | -|DISCOVER_PROPERTIES|+| | -|DBSCHEMA_CATALOGS|+| | -|DISCOVER_DATASOURCES|+| | -|DISCOVER_XML_METADATA|+| | -|DBSCHEMA_COLUMNS|+| | -|DBSCHEMA_PROVIDER_TYPES|+| | -|DBSCHEMA_SCHEMATA|+| | -|DBSCHEMA_SOURCE_TABLES|+| | -|DBSCHEMA_TABLES_INFO|+| | -|MDSCHEMA_HIERARCHIES|+| | -|MDSCHEMA_LEVELS|+| | -|MDSCHEMA_MEASUREGROUP_DIMENSIONS|+| | -|MDSCHEMA_MEASURES|+| | -|MDSCHEMA_MEMBERS|+| | -|MDSCHEMA_PROPERTIES|+| | -|MDSCHEMA_SETS|+| | -|MDSCHEMA_KPIS|+| | -|MDSCHEMA_MEASUREGROUPS|+| | -|DISCOVER_INSTANCES|-| | -|MDSCHEMA_INPUT_DATASOURCES|-| | -|DMSCHEMA_MINING_SERVICES|-| | -|DMSCHEMA_MINING_SERVICE_PARAMETERS|-| | -|DMSCHEMA_MINING_FUNCTIONS|-| | -|DMSCHEMA_MINING_MODEL_CONTENT|-| | -|DMSCHEMA_MINING_MODEL_XML|-| | -|DMSCHEMA_MINING_MODEL_CONTENT_PMML|-| | -|DMSCHEMA_MINING_MODELS|-| | -|DMSCHEMA_MINING_COLUMNS|-| | -|DMSCHEMA_MINING_STRUCTURES|-| | -|DMSCHEMA_MINING_STRUCTURE_COLUMNS|-| | -|DISCOVER_TRACES|-| | -|DISCOVER_TRACE_DEFINITION_PROVIDERINFO|-| | -|DISCOVER_TRACE_COLUMNS|-| | -|DISCOVER_TRACE_EVENT_CATEGORIES|-| | -|DISCOVER_MEMORYUSAGE|-| | -|DISCOVER_MEMORYGRANT|-| | -|DISCOVER_LOCKS|-| | -|DISCOVER_CONNECTIONS|-| | -|DISCOVER_SESSIONS|-| | -|DISCOVER_JOBS|-| | -|DISCOVER_TRANSACTIONS|-| | -|DISCOVER_DB_CONNECTIONS|-| | -|DISCOVER_MASTER_KEY|-| | -|DISCOVER_PERFORMANCE_COUNTERS|-| | -|DISCOVER_LOCATIONS|-| | -|DISCOVER_PARTITION_DIMENSION_STAT|-| | -|DISCOVER_PARTITION_STAT|-| | -|DISCOVER_DIMENSION_STAT|-| | -|DISCOVER_COMMANDS|-| | -|DISCOVER_COMMAND_OBJECTS|-| | -|DISCOVER_OBJECT_ACTIVITY|-| | -|DISCOVER_OBJECT_MEMORY_USAGE|-| | -|DISCOVER_STORAGE_TABLES|-| | -|DISCOVER_STORAGE_TABLE_COLUMNS|-| | -|DISCOVER_STORAGE_TABLE_COLUMN_SEGMENTS|-| | -|DISCOVER_CSDL_METADATA|-| | -|DISCOVER_CALC_DEPENDENCY|-| | -|DISCOVER_RING_BUFFERS|-| | -|DISCOVER_XEVENT_TRACE_DEFINITION|-| | -|DISCOVER_XEVENT_PACKAGES|-| | -|DISCOVER_XEVENT_OBJECTS|-| | -|DISCOVER_XEVENT_OBJECT_COLUMNS|-| | -|DISCOVER_XEVENT_SESSIONS|-| | -|DISCOVER_XEVENT_SESSION_TARGETS|-| | -|DISCOVER_MEM_STATS|-| | -|DISCOVER_DB_MEM_STATS|-| | -|DISCOVER_OBJECT_COUNTERS|-| | diff --git a/model/csdl/v2/bi/model/bi.ecore b/model/csdl/v2/bi/model/bi.ecore index 11433775..8f319d2b 100644 --- a/model/csdl/v2/bi/model/bi.ecore +++ b/model/csdl/v2/bi/model/bi.ecore @@ -14,7 +14,7 @@ xmlns:ecore="http://www.eclipse.org/emf/2002/Ecore" name="bi" nsURI="http://schemas.microsoft.com/sqlbi/2010/10/edm/extensions" nsPrefix="bi"> -
+
diff --git a/model/csdl/v2/edm/model/edm.ecore b/model/csdl/v2/edm/model/edm.ecore index 91811163..c6a76e55 100644 --- a/model/csdl/v2/edm/model/edm.ecore +++ b/model/csdl/v2/edm/model/edm.ecore @@ -13,7 +13,7 @@ -
+
diff --git a/model/empty/model/empty.ecore b/model/empty/model/empty.ecore index ef7480bd..9793bbb7 100644 --- a/model/empty/model/empty.ecore +++ b/model/empty/model/empty.ecore @@ -24,7 +24,7 @@ -
+
diff --git a/model/engine/model/engine.ecore b/model/engine/model/engine.ecore index d98e85e5..52824b5b 100644 --- a/model/engine/model/engine.ecore +++ b/model/engine/model/engine.ecore @@ -84,7 +84,7 @@ -
+
@@ -93,7 +93,7 @@ -
+
@@ -160,7 +160,7 @@ -
+
@@ -194,7 +194,7 @@ -
+
@@ -298,7 +298,7 @@ -
+
@@ -328,7 +328,7 @@ -
+
@@ -376,7 +376,7 @@ -
+
@@ -424,7 +424,7 @@ -
+
@@ -527,7 +527,7 @@ -
+
@@ -561,7 +561,7 @@ -
+
@@ -595,7 +595,7 @@ -
+
@@ -626,7 +626,7 @@ -
+
@@ -655,7 +655,7 @@ -
+
@@ -663,7 +663,7 @@ -
+
@@ -695,7 +695,7 @@ -
+
@@ -761,7 +761,7 @@ -
+
@@ -802,7 +802,7 @@ -
+
@@ -886,7 +886,7 @@ -
+
@@ -998,7 +998,7 @@ -
+
@@ -1075,7 +1075,7 @@ -
+
@@ -1095,7 +1095,7 @@ -
+
@@ -1260,7 +1260,7 @@ -
+
@@ -1283,7 +1283,7 @@ -
+
@@ -1371,7 +1371,7 @@ -
+
@@ -1412,7 +1412,7 @@ -
+
@@ -1443,7 +1443,7 @@ -
+
@@ -1462,7 +1462,7 @@ -
+
@@ -1516,7 +1516,7 @@ -
+
@@ -1526,7 +1526,7 @@ -
+
@@ -1807,7 +1807,7 @@ -
+
@@ -1818,7 +1818,7 @@ -
+
@@ -1896,7 +1896,7 @@ -
+
@@ -1963,7 +1963,7 @@ -
+
@@ -2070,7 +2070,7 @@ -
+
@@ -2084,7 +2084,7 @@ -
+
@@ -2112,7 +2112,7 @@ -
+
@@ -2164,7 +2164,7 @@ -
+
@@ -2244,7 +2244,7 @@ -
+
@@ -2309,7 +2309,7 @@ -
+
@@ -2365,7 +2365,7 @@ -
+
@@ -2406,7 +2406,7 @@ -
+
@@ -2466,7 +2466,7 @@ -
+
@@ -2601,7 +2601,7 @@ -
+
@@ -2765,7 +2765,7 @@ -
+
@@ -2785,7 +2785,7 @@ -
+
@@ -2851,7 +2851,7 @@ -
+
@@ -2871,7 +2871,7 @@ -
+
@@ -3004,7 +3004,7 @@ -
+
@@ -3277,7 +3277,7 @@ -
+
@@ -3308,7 +3308,7 @@ -
+
@@ -3319,7 +3319,7 @@ -
+
@@ -3330,7 +3330,7 @@ -
+
@@ -3416,7 +3416,7 @@ -
+
@@ -3492,7 +3492,7 @@ -
+
@@ -3600,7 +3600,7 @@ -
+
@@ -3786,7 +3786,7 @@ -
+
@@ -3808,7 +3808,7 @@ -
+
@@ -4230,7 +4230,7 @@ -
+
@@ -4241,7 +4241,7 @@ -
+
@@ -4263,7 +4263,7 @@ -
+
@@ -4274,7 +4274,7 @@ -
+
@@ -4337,7 +4337,7 @@ -
+
@@ -4371,7 +4371,7 @@ -
+
@@ -4382,7 +4382,7 @@ -
+
@@ -4612,7 +4612,7 @@ -
+
@@ -4718,7 +4718,7 @@ -
+
@@ -4749,7 +4749,7 @@ -
+
@@ -4769,7 +4769,7 @@ -
+
@@ -4778,7 +4778,7 @@ -
+
@@ -4789,7 +4789,7 @@ -
+
@@ -4799,7 +4799,7 @@ -
+
@@ -4809,7 +4809,7 @@ -
+
@@ -4820,7 +4820,7 @@ -
+
@@ -4853,7 +4853,7 @@ -
+
@@ -5016,7 +5016,7 @@ -
+
@@ -5027,7 +5027,7 @@ -
+
@@ -5036,7 +5036,7 @@ -
+
@@ -5078,7 +5078,7 @@ -
+
@@ -5115,7 +5115,7 @@ -
+
@@ -5238,7 +5238,7 @@ -
+
@@ -5248,7 +5248,7 @@ -
+
@@ -5293,7 +5293,7 @@ -
+
@@ -5381,7 +5381,7 @@ -
+
@@ -5424,7 +5424,7 @@ -
+
@@ -5683,7 +5683,7 @@ -
+
@@ -5776,7 +5776,7 @@ -
+
@@ -5887,7 +5887,7 @@ -
+
@@ -5897,7 +5897,7 @@ -
+
@@ -5966,7 +5966,7 @@ -
+
@@ -5986,7 +5986,7 @@ -
+
@@ -6270,7 +6270,7 @@ -
+
@@ -6329,7 +6329,7 @@ -
+
@@ -6413,7 +6413,7 @@ -
+
@@ -6422,7 +6422,7 @@ -
+
@@ -6442,7 +6442,7 @@ -
+
@@ -6471,7 +6471,7 @@ -
+
@@ -6526,7 +6526,7 @@ -
+
@@ -6610,7 +6610,7 @@ -
+
@@ -6620,7 +6620,7 @@ -
+
@@ -6665,7 +6665,7 @@ -
+
@@ -6725,7 +6725,7 @@ -
+
@@ -6792,7 +6792,7 @@ -
+
@@ -6832,7 +6832,7 @@ -
+
@@ -6841,7 +6841,7 @@ -
+
@@ -6852,7 +6852,7 @@ -
+
@@ -6941,7 +6941,7 @@ -
+
@@ -7077,7 +7077,7 @@ -
+
@@ -7086,7 +7086,7 @@ -
+
@@ -7122,7 +7122,7 @@ -
+
@@ -7380,7 +7380,7 @@ -
+
@@ -7413,7 +7413,7 @@ -
+
@@ -7500,7 +7500,7 @@ -
+
@@ -7511,7 +7511,7 @@ -
+
@@ -7660,7 +7660,7 @@ -
+
@@ -7694,7 +7694,7 @@ -
+
@@ -7724,7 +7724,7 @@ -
+
@@ -7749,7 +7749,7 @@ -
+
@@ -7793,7 +7793,7 @@ -
+
@@ -7855,7 +7855,7 @@ -
+
@@ -7889,7 +7889,7 @@ -
+
@@ -7922,7 +7922,7 @@ -
+
@@ -7956,7 +7956,7 @@ -
+
@@ -8004,7 +8004,7 @@ -
+
@@ -8104,7 +8104,7 @@ -
+
@@ -8155,7 +8155,7 @@ -
+
@@ -8164,7 +8164,7 @@ -
+
@@ -8263,7 +8263,7 @@ -
+
@@ -8293,7 +8293,7 @@ -
+
@@ -8401,7 +8401,7 @@ -
+
@@ -8457,7 +8457,7 @@ -
+
@@ -8477,7 +8477,7 @@ -
+
@@ -8676,7 +8676,7 @@ -
+
@@ -8705,7 +8705,7 @@ -
+
@@ -8734,7 +8734,7 @@ -
+
@@ -8803,7 +8803,7 @@ -
+
@@ -8823,7 +8823,7 @@ -
+
@@ -9017,7 +9017,7 @@ -
+
@@ -9092,7 +9092,7 @@ -
+
@@ -9102,7 +9102,7 @@ -
+
@@ -9237,7 +9237,7 @@ -
+
@@ -9316,7 +9316,7 @@ -
+
@@ -9477,7 +9477,7 @@ -
+
@@ -9517,7 +9517,7 @@ -
+
@@ -9579,7 +9579,7 @@ -
+
@@ -9621,7 +9621,7 @@ -
+
@@ -9769,7 +9769,7 @@ -
+
@@ -9843,7 +9843,7 @@ -
+
@@ -9894,7 +9894,7 @@ -
+
@@ -10007,7 +10007,7 @@ -
+
@@ -10071,7 +10071,7 @@ -
+
@@ -11684,7 +11684,7 @@ -
+
@@ -12057,13 +12057,13 @@ -
+
-
+
@@ -12801,7 +12801,7 @@ -
+
diff --git a/model/engine/pom.xml b/model/engine/pom.xml index 046978c8..7d580d30 100644 --- a/model/engine/pom.xml +++ b/model/engine/pom.xml @@ -65,6 +65,16 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + + + false + + org.eclipse.daanse org.eclipse.daanse.tooling.emf.codegen.maven diff --git a/model/engine200_200/model/engine200_200.ecore b/model/engine200_200/model/engine200_200.ecore index 94f26338..0da00b31 100644 --- a/model/engine200_200/model/engine200_200.ecore +++ b/model/engine200_200/model/engine200_200.ecore @@ -25,7 +25,7 @@ -
+
diff --git a/model/engine300/model/engine300.ecore b/model/engine300/model/engine300.ecore index 280c14d1..45428fae 100644 --- a/model/engine300/model/engine300.ecore +++ b/model/engine300/model/engine300.ecore @@ -25,7 +25,7 @@ -
+
@@ -159,7 +159,7 @@ -
+
diff --git a/model/engine300_300/model/engine300_300.ecore b/model/engine300_300/model/engine300_300.ecore index a58cb63f..81018a58 100644 --- a/model/engine300_300/model/engine300_300.ecore +++ b/model/engine300_300/model/engine300_300.ecore @@ -24,7 +24,7 @@ -
+
@@ -371,7 +371,7 @@ -
+
@@ -548,7 +548,7 @@ -
+
@@ -584,7 +584,7 @@ -
+
@@ -604,7 +604,7 @@ -
+
@@ -662,7 +662,7 @@ -
+
@@ -670,7 +670,7 @@ -
+
diff --git a/model/exception/model/exception.ecore b/model/exception/model/exception.ecore index 17b3fd6e..a969bb6b 100644 --- a/model/exception/model/exception.ecore +++ b/model/exception/model/exception.ecore @@ -30,7 +30,7 @@ -
+
@@ -69,7 +69,7 @@ -
+
@@ -128,7 +128,7 @@ -
+
@@ -239,7 +239,7 @@ -
+
@@ -322,7 +322,7 @@ -
+
diff --git a/model/ext/model/ext.ecore b/model/ext/model/ext.ecore index bc96e4a8..e641bcb5 100644 --- a/model/ext/model/ext.ecore +++ b/model/ext/model/ext.ecore @@ -24,7 +24,7 @@ -
+
diff --git a/model/io/pom.xml b/model/io/pom.xml index 2b7d8c93..8422a14a 100644 --- a/model/io/pom.xml +++ b/model/io/pom.xml @@ -26,7 +26,7 @@ client can use it. - + org.eclipse.daanse org.eclipse.daanse.xmla.model.rowset.core ${revision} diff --git a/model/mddataset/model/mddataset.ecore b/model/mddataset/model/mddataset.ecore index 6c59a681..d8895ce1 100644 --- a/model/mddataset/model/mddataset.ecore +++ b/model/mddataset/model/mddataset.ecore @@ -24,7 +24,7 @@ -
+
@@ -130,7 +130,7 @@ -
+
@@ -150,7 +150,7 @@ -
+
@@ -170,7 +170,7 @@ -
+
@@ -207,7 +207,7 @@ -
+
@@ -227,7 +227,7 @@ -
+
@@ -253,7 +253,7 @@ -
+
@@ -276,7 +276,7 @@ -
+
@@ -293,7 +293,7 @@ -
+
@@ -336,7 +336,7 @@ -
+
@@ -366,7 +366,7 @@ -
+
@@ -409,7 +409,7 @@ -
+
@@ -420,7 +420,7 @@ -
+
@@ -438,7 +438,7 @@ -
+
@@ -573,7 +573,7 @@ -
+
@@ -625,7 +625,7 @@ -
+
@@ -647,7 +647,7 @@ -
+
@@ -691,7 +691,7 @@ -
+
@@ -736,7 +736,7 @@ -
+
@@ -827,7 +827,7 @@ -
+
@@ -861,7 +861,7 @@ -
+
@@ -900,7 +900,7 @@ -
+
diff --git a/model/mddataset/pom.xml b/model/mddataset/pom.xml index b36a7bf0..b323554b 100644 --- a/model/mddataset/pom.xml +++ b/model/mddataset/pom.xml @@ -62,6 +62,16 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + + + false + + org.eclipse.daanse org.eclipse.daanse.tooling.emf.codegen.maven diff --git a/model/msxmla/model/msxmla.ecore b/model/msxmla/model/msxmla.ecore index ddd3ad80..7a249f33 100644 --- a/model/msxmla/model/msxmla.ecore +++ b/model/msxmla/model/msxmla.ecore @@ -88,7 +88,7 @@ -
+
diff --git a/model/multipleresults/model/multipleresults.ecore b/model/multipleresults/model/multipleresults.ecore index 546e761e..4f2f9e8e 100644 --- a/model/multipleresults/model/multipleresults.ecore +++ b/model/multipleresults/model/multipleresults.ecore @@ -24,7 +24,7 @@ -
+
diff --git a/model/rowset.core/model/rowset-core.ecore b/model/rowset.core/model/rowset-core.ecore index fe9178ba..aca0c782 100644 --- a/model/rowset.core/model/rowset-core.ecore +++ b/model/rowset.core/model/rowset-core.ecore @@ -51,7 +51,7 @@ -
+
@@ -118,7 +118,7 @@ -
+
@@ -128,7 +128,7 @@ -
+
@@ -139,7 +139,7 @@ -
+
@@ -216,7 +216,7 @@ -
+
@@ -241,7 +241,7 @@ -
+
@@ -318,7 +318,7 @@ -
+
@@ -394,7 +394,7 @@ -
+
@@ -529,7 +529,7 @@ -
+
@@ -540,7 +540,7 @@ -
+
@@ -724,7 +724,7 @@ -
+
diff --git a/model/rowset.mining/model/rowset-mining.ecore b/model/rowset.mining/model/rowset-mining.ecore index c02531b7..e21ca9c0 100644 --- a/model/rowset.mining/model/rowset-mining.ecore +++ b/model/rowset.mining/model/rowset-mining.ecore @@ -24,7 +24,7 @@ -
+
@@ -184,7 +184,7 @@ -
+
@@ -204,7 +204,7 @@ -
+
@@ -214,7 +214,7 @@ -
+
@@ -234,7 +234,7 @@ -
+
@@ -244,7 +244,7 @@ -
+
@@ -254,7 +254,7 @@ -
+
@@ -264,7 +264,7 @@ -
+
@@ -274,7 +274,7 @@ -
+
@@ -284,7 +284,7 @@ -
+
@@ -294,7 +294,7 @@ -
+
@@ -399,7 +399,7 @@ -
+
@@ -451,7 +451,7 @@ -
+
@@ -539,7 +539,7 @@ -
+
@@ -573,7 +573,7 @@ -
+
@@ -824,7 +824,7 @@ -
+
@@ -968,7 +968,7 @@ -
+
@@ -1053,7 +1053,7 @@ -
+
@@ -1138,7 +1138,7 @@ -
+
@@ -1162,7 +1162,7 @@ -
+
@@ -1215,7 +1215,7 @@ -
+
@@ -1226,7 +1226,7 @@ -
+
@@ -1237,7 +1237,7 @@ -
+
@@ -1432,7 +1432,7 @@ -
+
@@ -1550,7 +1550,7 @@ -
+
@@ -1572,7 +1572,7 @@ -
+
@@ -1715,7 +1715,7 @@ -
+
@@ -1737,7 +1737,7 @@ -
+
@@ -1799,7 +1799,7 @@ -
+
@@ -1809,7 +1809,7 @@ -
+
@@ -1893,7 +1893,7 @@ -
+
@@ -1923,7 +1923,7 @@ -
+
@@ -2277,7 +2277,7 @@ -
+
@@ -2402,7 +2402,7 @@ -
+
@@ -2552,7 +2552,7 @@ -
+
@@ -2622,7 +2622,7 @@ -
+
@@ -2668,7 +2668,7 @@ -
+
diff --git a/model/rowset.multidimensional/model/rowset-multidimensional.ecore b/model/rowset.multidimensional/model/rowset-multidimensional.ecore index a83dccb3..345d4e1d 100644 --- a/model/rowset.multidimensional/model/rowset-multidimensional.ecore +++ b/model/rowset.multidimensional/model/rowset-multidimensional.ecore @@ -149,7 +149,7 @@ -
+
@@ -203,7 +203,7 @@ -
+
@@ -223,7 +223,7 @@ -
+
@@ -243,7 +243,7 @@ -
+
@@ -264,7 +264,7 @@ -
+
@@ -324,7 +324,7 @@ -
+
@@ -335,7 +335,7 @@ -
+
@@ -470,7 +470,7 @@ -
+
@@ -522,7 +522,7 @@ -
+
@@ -729,7 +729,7 @@ -
+
@@ -829,7 +829,7 @@ -
+
@@ -850,7 +850,7 @@ -
+
@@ -861,7 +861,7 @@ -
+
@@ -871,7 +871,7 @@ -
+
@@ -891,7 +891,7 @@ -
+
@@ -963,7 +963,7 @@ -
+
@@ -1344,7 +1344,7 @@ -
+
@@ -1521,7 +1521,7 @@ -
+
@@ -1818,7 +1818,7 @@ -
+
@@ -1910,7 +1910,7 @@ -
+
@@ -2026,7 +2026,7 @@ -
+
@@ -2242,7 +2242,7 @@ -
+
@@ -2293,7 +2293,7 @@ -
+
@@ -2724,7 +2724,7 @@ -
+
@@ -2942,7 +2942,7 @@ -
+
@@ -3517,7 +3517,7 @@ -
+
diff --git a/model/rowset.relational/model/rowset-relational.ecore b/model/rowset.relational/model/rowset-relational.ecore index f31e23f1..da2c432f 100644 --- a/model/rowset.relational/model/rowset-relational.ecore +++ b/model/rowset.relational/model/rowset-relational.ecore @@ -78,7 +78,7 @@ -
+
@@ -111,7 +111,7 @@ -
+
@@ -212,7 +212,7 @@ -
+
@@ -488,7 +488,7 @@ -
+
@@ -553,7 +553,7 @@ -
+
@@ -563,7 +563,7 @@ -
+
@@ -583,7 +583,7 @@ -
+
@@ -644,7 +644,7 @@ -
+
@@ -674,7 +674,7 @@ -
+
@@ -1862,7 +1862,7 @@ -
+
@@ -1875,7 +1875,7 @@ -
+
@@ -1935,7 +1935,7 @@ -
+
@@ -1975,7 +1975,7 @@ -
+
@@ -2055,7 +2055,7 @@ -
+
@@ -2065,7 +2065,7 @@ -
+
@@ -2372,7 +2372,7 @@ -
+
@@ -4126,7 +4126,7 @@ -
+
diff --git a/model/rowset.server/model/rowset-server.ecore b/model/rowset.server/model/rowset-server.ecore index 6c2d9650..b032d113 100644 --- a/model/rowset.server/model/rowset-server.ecore +++ b/model/rowset.server/model/rowset-server.ecore @@ -127,7 +127,7 @@ -
+
@@ -250,7 +250,7 @@ -
+
@@ -392,7 +392,7 @@ -
+
@@ -425,7 +425,7 @@ -
+
@@ -500,7 +500,7 @@ -
+
@@ -589,7 +589,7 @@ -
+
@@ -706,7 +706,7 @@ -
+
@@ -810,7 +810,7 @@ -
+
@@ -863,7 +863,7 @@ -
+
@@ -916,7 +916,7 @@ -
+
@@ -1065,7 +1065,7 @@ -
+
@@ -1246,7 +1246,7 @@ -
+
@@ -1269,7 +1269,7 @@ -
+
@@ -1352,7 +1352,7 @@ -
+
@@ -1505,7 +1505,7 @@ -
+
@@ -1557,7 +1557,7 @@ -
+
@@ -1597,7 +1597,7 @@ -
+
@@ -1663,7 +1663,7 @@ -
+
@@ -1827,7 +1827,7 @@ -
+
@@ -1849,7 +1849,7 @@ -
+
@@ -1880,7 +1880,7 @@ -
+
@@ -2007,7 +2007,7 @@ -
+
@@ -2098,7 +2098,7 @@ -
+
@@ -2159,7 +2159,7 @@ -
+
@@ -2171,7 +2171,7 @@ -
+
@@ -2359,7 +2359,7 @@ -
+
@@ -2647,7 +2647,7 @@ -
+
@@ -2748,7 +2748,7 @@ -
+
@@ -2944,7 +2944,7 @@ -
+
@@ -3097,7 +3097,7 @@ -
+
@@ -3107,7 +3107,7 @@ -
+
@@ -3117,7 +3117,7 @@ -
+
@@ -3127,7 +3127,7 @@ -
+
@@ -3138,7 +3138,7 @@ -
+
@@ -3231,7 +3231,7 @@ -
+
@@ -3254,7 +3254,7 @@ -
+
@@ -3277,7 +3277,7 @@ -
+
@@ -3300,7 +3300,7 @@ -
+
@@ -3364,7 +3364,7 @@ -
+
@@ -3467,7 +3467,7 @@ -
+
@@ -3590,7 +3590,7 @@ -
+
@@ -3900,7 +3900,7 @@ -
+
@@ -4122,7 +4122,7 @@ -
+
@@ -4157,7 +4157,7 @@ eType="ecore:EDataType http://www.eclipse.org/emf/2003/XMLType#//LongObject" unsettable="true"> -
+
@@ -4470,7 +4470,7 @@
-
+
@@ -4891,7 +4891,7 @@ -
+
diff --git a/model/xmla/model/xmla.ecore b/model/xmla/model/xmla.ecore index 9273ff8c..f1fc4d46 100644 --- a/model/xmla/model/xmla.ecore +++ b/model/xmla/model/xmla.ecore @@ -158,7 +158,7 @@ -
+
@@ -258,17 +258,17 @@ -
+
-
+
-
+
@@ -288,107 +288,107 @@ -
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -445,7 +445,7 @@ -
+
@@ -501,7 +501,7 @@ -
+
@@ -543,7 +543,7 @@ -
+
@@ -579,7 +579,7 @@
-
+
@@ -610,7 +610,7 @@ -
+
@@ -744,7 +744,7 @@
-
+
@@ -889,7 +889,7 @@
-
+
@@ -1025,7 +1025,7 @@
-
+
@@ -1205,7 +1205,7 @@
-
+
@@ -1218,7 +1218,7 @@
-
+
@@ -1278,7 +1278,7 @@
-
+
@@ -1292,7 +1292,7 @@
-
+
@@ -1319,7 +1319,7 @@
-
+
@@ -1333,7 +1333,7 @@
-
+
@@ -1415,7 +1415,7 @@
-
+
@@ -1442,7 +1442,7 @@
-
+
@@ -1456,7 +1456,7 @@
-
+
@@ -1470,7 +1470,7 @@
-
+
@@ -1497,7 +1497,7 @@
-
+
@@ -1524,7 +1524,7 @@
-
+
@@ -1537,7 +1537,7 @@
-
+
@@ -1551,7 +1551,7 @@
-
+
@@ -1564,7 +1564,7 @@
-
+
@@ -1578,7 +1578,7 @@
-
+
@@ -1592,7 +1592,7 @@
-
+
@@ -1693,7 +1693,7 @@
-
+
@@ -1736,7 +1736,7 @@
-
+
@@ -1749,7 +1749,7 @@
-
+
@@ -1763,7 +1763,7 @@
-
+
@@ -1805,7 +1805,7 @@
-
+
@@ -1819,7 +1819,7 @@
-
+
@@ -1832,7 +1832,7 @@
-
+
@@ -1887,7 +1887,7 @@
-
+
@@ -1915,7 +1915,7 @@
-
+
@@ -1943,7 +1943,7 @@
-
+
@@ -1970,7 +1970,7 @@
-
+
@@ -1984,7 +1984,7 @@
-
+
@@ -1998,7 +1998,7 @@
-
+
@@ -2025,7 +2025,7 @@
-
+
@@ -2038,7 +2038,7 @@
-
+
@@ -2081,7 +2081,7 @@
-
+
@@ -2109,7 +2109,7 @@
-
+
@@ -2151,7 +2151,7 @@
-
+
@@ -2166,7 +2166,7 @@
-
+
@@ -2179,7 +2179,7 @@
-
+
@@ -2206,7 +2206,7 @@
-
+
@@ -2233,7 +2233,7 @@
-
+
@@ -2260,7 +2260,7 @@
-
+
@@ -2274,7 +2274,7 @@
-
+
@@ -2538,7 +2538,7 @@ -
+
@@ -2604,7 +2604,7 @@ -
+
@@ -2656,7 +2656,7 @@ -
+
@@ -2727,7 +2727,7 @@ -
+
@@ -2779,7 +2779,7 @@ -
+
@@ -2962,7 +2962,7 @@
-
+
@@ -2977,7 +2977,7 @@ -
+
@@ -2999,7 +2999,7 @@ -
+
@@ -3052,7 +3052,7 @@ -
+
@@ -3084,7 +3084,7 @@ -
+
@@ -3171,7 +3171,7 @@ -
+
@@ -3204,7 +3204,7 @@ -
+
@@ -3249,7 +3249,7 @@ -
+
@@ -3283,7 +3283,7 @@ -
+
@@ -3326,7 +3326,7 @@ -
+
@@ -3360,7 +3360,7 @@ -
+
@@ -3395,7 +3395,7 @@ -
+
@@ -3483,7 +3483,7 @@ -
+
@@ -3618,7 +3618,7 @@ -
+
@@ -3640,7 +3640,7 @@ -
+
@@ -3651,7 +3651,7 @@ -
+
@@ -3715,7 +3715,7 @@ -
+
@@ -3737,7 +3737,7 @@ -
+
@@ -3802,7 +3802,7 @@ -
+
@@ -3825,7 +3825,7 @@ -
+
@@ -3858,7 +3858,7 @@
-
+
@@ -3886,7 +3886,7 @@
-
+
@@ -3909,7 +3909,7 @@
-
+
@@ -4017,7 +4017,7 @@
-
+
@@ -4050,7 +4050,7 @@
-
+
@@ -4083,7 +4083,7 @@
-
+
@@ -4105,7 +4105,7 @@
-
+
@@ -4196,7 +4196,7 @@ -
+
@@ -4318,7 +4318,7 @@ -
+
@@ -4346,7 +4346,7 @@ -
+
@@ -4379,7 +4379,7 @@ -
+
@@ -4408,7 +4408,7 @@ -
+
@@ -4439,7 +4439,7 @@ -
+
@@ -4448,7 +4448,7 @@ -
+
@@ -4478,7 +4478,7 @@ -
+
@@ -4683,7 +4683,7 @@ -
+
@@ -4932,7 +4932,7 @@ -
+
@@ -4963,7 +4963,7 @@ -
+
@@ -5016,7 +5016,7 @@ -
+
@@ -5036,7 +5036,7 @@ -
+
@@ -5056,7 +5056,7 @@ -
+
@@ -5076,7 +5076,7 @@ -
+
@@ -5106,7 +5106,7 @@ -
+
@@ -5126,7 +5126,7 @@ -
+
@@ -5159,7 +5159,7 @@ -
+
diff --git a/pom.xml b/pom.xml index ce401bc8..abe7b041 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ org.eclipse.daanse org.eclipse.daanse.pom.parent - 0.0.6 + 0.0.7 org.eclipse.daanse.xmla ${revision} @@ -80,6 +80,39 @@ server + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + *.configuration + + + + generated + a + Generated by EMF: + + + model + a + Ecore model: + + + ordered + a + Ordered: + + + + + + + + diff --git a/server/adapter.emf/src/main/java/org/eclipse/daanse/xmla/server/adapter/emf/package-info.java b/server/adapter.emf/src/main/java/org/eclipse/daanse/xmla/server/adapter/emf/package-info.java index db2342c5..8c0cfb68 100644 --- a/server/adapter.emf/src/main/java/org/eclipse/daanse/xmla/server/adapter/emf/package-info.java +++ b/server/adapter.emf/src/main/java/org/eclipse/daanse/xmla/server/adapter/emf/package-info.java @@ -16,12 +16,12 @@ * EMF-based XMLA server adapter. * *

- * Serves a Discover request from a byte stream: - * {@link org.eclipse.daanse.xmla.server.adapter.emf.EmfXmlaApiAdapter} reads - * the envelope, {@code DiscoverRequests} builds the api request record from the - * restrictions, and {@code RowConverters} turns the answer into the row objects - * the model describes. The last two are generated from the model and the api, - * so a column can only be lost by a change that stops the build. + * {@link org.eclipse.daanse.xmla.server.adapter.emf.EmfXmlaAdapter} reads one SOAP + * envelope off a stream and writes one back: session headers first, then identity, then + * the body dispatched to the connector, whose rows are already the EObjects the model + * describes. There is no record layer and no converter between. + * {@link org.eclipse.daanse.xmla.server.adapter.emf.AccessPolicy} decides what an + * anonymous caller may ask for. */ @org.osgi.annotation.bundle.Export @org.osgi.annotation.versioning.Version("0.0.1") diff --git a/server/auth.basic/pom.xml b/server/auth.basic/pom.xml new file mode 100644 index 00000000..29c49837 --- /dev/null +++ b/server/auth.basic/pom.xml @@ -0,0 +1,53 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.basic + Daanse XMLA Authentication: HTTP BASIC + HTTP BASIC as an XmlaAuthenticator service, verifying against whatever + XmlaCredentials implementation a deployment registers. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.basic/src/main/java/org/eclipse/daanse/xmla/server/auth/basic/BasicAuthenticator.java b/server/auth.basic/src/main/java/org/eclipse/daanse/xmla/server/auth/basic/BasicAuthenticator.java new file mode 100644 index 00000000..57ff92ae --- /dev/null +++ b/server/auth.basic/src/main/java/org/eclipse/daanse/xmla/server/auth/basic/BasicAuthenticator.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.basic; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Optional; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthRanking; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.RoleResolution; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.eclipse.daanse.xmla.api.auth.XmlaCredentials; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; + +/** + * HTTP BASIC over whatever {@link XmlaCredentials} a deployment registers. + *

+ * BASIC sends the password in the clear on every request, so it is only honest + * over TLS or on a loopback interface. It is here because it is what Analysis + * Services clients - Excel, msolap.dll, every XMLA library - actually send. + *

+ * The credential store is a mandatory reference: an endpoint configured for + * BASIC without one would either refuse everyone or admit everyone, and this + * component simply does not come up instead. + */ +@Component(service = XmlaAuthenticator.class, property = "service.ranking:Integer=" + AuthRanking.BASIC) +@Designate(ocd = BasicAuthenticator.Config.class) +public class BasicAuthenticator implements XmlaAuthenticator { + + private static final String SCHEME = "Basic"; + private static final String PREFIX = SCHEME + " "; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + XmlaCredentials credentials; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + RoleResolution roles; + + private volatile String realm; + private volatile int maxLength; + + @ObjectClassDefinition + @interface Config { + + /** The realm name a BASIC challenge shows the user. */ + String realm() default "Daanse XMLA"; + + /** + * The longest {@code Authorization} value that will be decoded at all, so an + * unauthenticated caller cannot make this server allocate at will. + */ + int maxCredentialsLength() default 4096; + } + + @Activate + void activate(Config config) { + realm = config.realm(); + maxLength = config.maxCredentialsLength(); + } + + @Override + public String scheme() { + return SCHEME; + } + + /** + * The {@code charset} parameter is RFC 7617's only way to say which encoding + * the password is in. Without it Windows clients send their ANSI code page, and + * a correct password containing anything outside ASCII is rejected for good, + * with nothing to see anywhere. + */ + @Override + public String challenge() { + return PREFIX + "realm=\"" + realm + "\", charset=\"UTF-8\""; + } + + @Override + public Result authenticate(XmlaRequest request) { + String authorization = request.header("Authorization"); + if (authorization == null || !authorization.regionMatches(true, 0, SCHEME, 0, SCHEME.length())) { + return new Result.NotMine(); + } + String encoded = authorization.length() > PREFIX.length() ? authorization.substring(PREFIX.length()).trim() + : ""; + if (encoded.isEmpty()) { + // Some clients probe with a bare scheme name; answering the challenge is more + // useful than falling through to anonymous. + return new Result.Challenge(challenge()); + } + if (encoded.length() > maxLength) { + return new Result.Refused("the Basic credentials are longer than this endpoint accepts"); + } + + byte[] decoded; + try { + decoded = Base64.getMimeDecoder().decode(encoded); + } catch (IllegalArgumentException notBase64) { + return new Result.Refused("the Basic credentials are not base64"); + } + try { + int colon = indexOfColon(decoded); + if (colon < 0) { + return new Result.Refused("the Basic credentials carry no ':' separator"); + } + // The password never becomes a String: one would stay in the heap until the + // garbage collector got to it, and could not be overwritten, which would make + // the scrubbing below decorative. + String user = decode(decoded, 0, colon).toString(); + char[] password = charsOf(decode(decoded, colon + 1, decoded.length - colon - 1)); + try { + return verify(user, password); + } finally { + Arrays.fill(password, '\0'); + } + } finally { + Arrays.fill(decoded, (byte) 0); + } + } + + private Result verify(String user, char[] password) { + Optional verified = credentials.verify(user, password); + if (verified.isEmpty()) { + // "no such user" and "wrong password" answer identically on purpose - the + // difference is only useful to someone enumerating accounts. + return new Result.Refused("the credentials were not accepted"); + } + AuthenticatedIdentity identity = verified.get(); + return Result.Authenticated.of(identity.withRoles(roles.resolve(identity.principal(), identity.claims()))); + } + + private static int indexOfColon(byte[] pair) { + for (int index = 0; index < pair.length; index++) { + if (pair[index] == ':') { + return index; + } + } + return -1; + } + + /** + * UTF-8 as RFC 7617 recommends, falling back to the code page Windows clients + * send when the bytes are not valid UTF-8. + */ + private static CharBuffer decode(byte[] bytes, int offset, int length) { + ByteBuffer source = ByteBuffer.wrap(bytes, offset, length); + CharsetDecoder strict = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return strict.decode(source); + } catch (CharacterCodingException notUtf8) { + return StandardCharsets.ISO_8859_1.decode(ByteBuffer.wrap(bytes, offset, length)); + } + } + + private static char[] charsOf(CharBuffer buffer) { + char[] chars = new char[buffer.remaining()]; + buffer.get(chars); + if (buffer.hasArray()) { + Arrays.fill(buffer.array(), '\0'); + } + return chars; + } +} diff --git a/server/auth.basic/src/main/java/org/eclipse/daanse/xmla/server/auth/basic/package-info.java b/server/auth.basic/src/main/java/org/eclipse/daanse/xmla/server/auth/basic/package-info.java new file mode 100644 index 00000000..f38a7b57 --- /dev/null +++ b/server/auth.basic/src/main/java/org/eclipse/daanse/xmla/server/auth/basic/package-info.java @@ -0,0 +1,25 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * HTTP BASIC. + *

+ * {@code BasicAuthenticator} decodes the {@code Authorization} header and hands + * the credentials to whatever credential store the deployment registered. It is + * here because it is what Analysis Services clients actually send, and it is + * only honest over TLS. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for how a mechanism fits + * together with the stores, the role providers and the chain. + */ +package org.eclipse.daanse.xmla.server.auth.basic; diff --git a/server/auth.basic/src/test/java/org/eclipse/daanse/xmla/server/auth/basic/BasicAuthenticatorTest.java b/server/auth.basic/src/test/java/org/eclipse/daanse/xmla/server/auth/basic/BasicAuthenticatorTest.java new file mode 100644 index 00000000..d191534e --- /dev/null +++ b/server/auth.basic/src/test/java/org/eclipse/daanse/xmla/server/auth/basic/BasicAuthenticatorTest.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.basic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.annotation.Annotation; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * What arrives in an {@code Authorization} header and what becomes of it. + *

+ * The encoding cases are the ones that mattered in practice: a password with a + * character outside ASCII used to fail permanently and silently against Windows + * clients, which send their ANSI code page rather than UTF-8. + */ +class BasicAuthenticatorTest { + + private static final String PASSWORD = "gehe1mß"; + + private BasicAuthenticator authenticator; + private int resolutions; + + @BeforeEach + void wire() { + authenticator = new BasicAuthenticator(); + authenticator.credentials = (user, password) -> "alice".equals(user) && PASSWORD.equals(new String(password)) + ? Optional.of(AuthenticatedIdentity.of(new NamedPrincipal(user), Claims.none())) + : Optional.empty(); + authenticator.roles = (principal, claims, external) -> { + resolutions++; + return Set.of("Admin"); + }; + authenticator.activate(config(4096)); + } + + private static BasicAuthenticator.Config config(int maxLength) { + return new BasicAuthenticator.Config() { + + @Override + public Class annotationType() { + return BasicAuthenticator.Config.class; + } + + @Override + public String realm() { + return "Daanse XMLA"; + } + + @Override + public int maxCredentialsLength() { + return maxLength; + } + }; + } + + private static XmlaRequest with(String authorization) { + Map> headers = authorization == null ? Map.of() + : Map.of("Authorization", List.of(authorization)); + return new XmlaRequest(null, null, headers, null, null); + } + + private static String credentials(String user, String password, Charset charset) { + return "Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes(charset)); + } + + @Test + void aRequestWithNoCredentialsIsNotThisMechanismsBusiness() { + assertThat(authenticator.authenticate(with(null))).isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + assertThat(authenticator.authenticate(with("Bearer abc"))).isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + @Test + void theBareSchemeIsAnswerdWithTheChallenge() { + // Some clients probe with it; falling through to anonymous tells them nothing. + assertThat(authenticator.authenticate(with("Basic"))).isInstanceOf(XmlaAuthenticator.Result.Challenge.class); + } + + @Test + void theChallengeNamesTheEncoding() { + // RFC 7617's only way to say which encoding the password is in. + assertThat(authenticator.challenge()).contains("charset=\"UTF-8\""); + } + + @Test + void theRightPasswordIsAcceptedAndGetsItsRoles() { + XmlaAuthenticator.Result result = authenticator + .authenticate(with(credentials("alice", PASSWORD, StandardCharsets.UTF_8))); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + AuthenticatedIdentity identity = ((XmlaAuthenticator.Result.Authenticated) result).identity(); + assertThat(identity.name()).isEqualTo("alice"); + assertThat(identity.roles()).containsExactly("Admin"); + assertThat(resolutions).as("the roles are resolved once").isEqualTo(1); + } + + @Test + void aWindowsClientsCodePageIsAcceptedToo() { + // The bytes are not valid UTF-8, and before the fallback this was an + // indistinguishable "wrong password" forever. + XmlaAuthenticator.Result result = authenticator + .authenticate(with(credentials("alice", PASSWORD, StandardCharsets.ISO_8859_1))); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + } + + @Test + void theWrongPasswordIsRefused() { + assertThat(authenticator.authenticate(with(credentials("alice", "wrong", StandardCharsets.UTF_8)))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void credentialsThatAreNotBase64AreRefused() { + assertThat(authenticator.authenticate(with("Basic ~~~not base64~~~"))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void credentialsWithNoSeparatorAreRefused() { + String encoded = Base64.getEncoder().encodeToString("nocolonhere".getBytes(StandardCharsets.UTF_8)); + + assertThat(authenticator.authenticate(with("Basic " + encoded))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void anOversizedHeaderIsRefusedBeforeItIsDecoded() { + authenticator.activate(config(16)); + + assertThat(authenticator.authenticate(with(credentials("alice", PASSWORD, StandardCharsets.UTF_8)))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void theSchemeIsMatchedWithoutRegardToCase() { + assertThat(authenticator + .authenticate(with(credentials("alice", PASSWORD, StandardCharsets.UTF_8).replace("Basic", "basic")))) + .isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + } +} diff --git a/server/auth.dummy/pom.xml b/server/auth.dummy/pom.xml new file mode 100644 index 00000000..5e863039 --- /dev/null +++ b/server/auth.dummy/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.dummy + Daanse XMLA Authentication: Fixed Identity (development) + A fixed identity for a deployment that runs without authentication: every + caller becomes the configured user with the configured roles. Verifies nothing and is + not security - it exists so that role-dependent behaviour can be exercised without a + directory. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.dummy/src/main/java/org/eclipse/daanse/xmla/server/auth/dummy/FixedIdentityAuthenticator.java b/server/auth.dummy/src/main/java/org/eclipse/daanse/xmla/server/auth/dummy/FixedIdentityAuthenticator.java new file mode 100644 index 00000000..410a2c25 --- /dev/null +++ b/server/auth.dummy/src/main/java/org/eclipse/daanse/xmla/server/auth/dummy/FixedIdentityAuthenticator.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.dummy; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthRanking; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Every caller nobody identified is the same configured user. + *

+ * An endpoint with no mechanism registered serves everyone anonymously, which + * is a valid way to run and the default. What it cannot do is exercise anything + * that depends on who is calling: a catalog's roles, a connector's + * per-user behaviour, a rowset restricted to authenticated callers. This fills + * that gap by declaring one identity for callers nothing else claimed. + *

+ * It verifies nothing, and three things follow from that, all of them load + * bearing. It answers {@link Result.Fallback}, so it can never displace a + * mechanism that would have authenticated the caller for real, never displace + * the identity a session carries, and never satisfy an access rule that demands + * authentication. It ranks below everything. And it requires a configuration + * that says out loud that the endpoint is unprotected, so that installing the + * bundle is not by itself enough to name every caller. + */ +@Component(service = XmlaAuthenticator.class, configurationPolicy = ConfigurationPolicy.REQUIRE, property = "service.ranking:Integer=" + + AuthRanking.FIXED) +@Designate(ocd = FixedIdentityAuthenticator.Config.class) +public class FixedIdentityAuthenticator implements XmlaAuthenticator { + + private static final Logger LOGGER = LoggerFactory.getLogger(FixedIdentityAuthenticator.class); + + private volatile AuthenticatedIdentity identity; + + @ObjectClassDefinition + @interface Config { + + /** + * Confirms that this endpoint is knowingly left unauthenticated. Without it the + * component does not come up, so the decision cannot be made by accident. + */ + boolean acknowledgeUnauthenticated() default false; + + /** The name every unidentified caller is given. */ + String userName() default "daanse"; + + /** The roles that caller holds. Empty means the catalog's default role. */ + String[] roles() default {}; + } + + @Activate + void activate(Config config) { + if (!config.acknowledgeUnauthenticated()) { + throw new IllegalStateException("this component names every caller without verifying anything; " + + "set acknowledgeUnauthenticated to confirm that is intended"); + } + Set granted = new LinkedHashSet<>(); + for (String role : config.roles()) { + if (role != null && !role.isBlank()) { + granted.add(role.trim()); + } + } + identity = new AuthenticatedIdentity(new NamedPrincipal(config.userName()), granted, Claims.none()); + LOGGER.warn("callers nobody identified are served as '{}' with roles {}: this endpoint is not authenticated", + config.userName(), granted); + } + + @Override + public String scheme() { + return "Fixed"; + } + + @Override + public String challenge() { + // Nothing to ask a client for; the identity does not come from the request. + return ""; + } + + @Override + public Result authenticate(XmlaRequest request) { + return new Result.Fallback(identity); + } +} diff --git a/server/auth.dummy/src/main/java/org/eclipse/daanse/xmla/server/auth/dummy/package-info.java b/server/auth.dummy/src/main/java/org/eclipse/daanse/xmla/server/auth/dummy/package-info.java new file mode 100644 index 00000000..46b676d1 --- /dev/null +++ b/server/auth.dummy/src/main/java/org/eclipse/daanse/xmla/server/auth/dummy/package-info.java @@ -0,0 +1,26 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * A configured identity for callers nothing identified. + *

+ * {@code FixedIdentityAuthenticator} verifies nothing. It exists so a + * development or demonstration endpoint can exercise everything that depends on + * who is calling. It answers {@code Result.Fallback}, so it can never displace + * a mechanism that would have authenticated the caller for real, and it + * requires a configuration that says out loud that the endpoint is unprotected. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for the rules a fallback + * identity is subject to. + */ +package org.eclipse.daanse.xmla.server.auth.dummy; diff --git a/server/auth.dummy/src/test/java/org/eclipse/daanse/xmla/server/auth/dummy/FixedIdentityAuthenticatorTest.java b/server/auth.dummy/src/test/java/org/eclipse/daanse/xmla/server/auth/dummy/FixedIdentityAuthenticatorTest.java new file mode 100644 index 00000000..61447ed3 --- /dev/null +++ b/server/auth.dummy/src/test/java/org/eclipse/daanse/xmla/server/auth/dummy/FixedIdentityAuthenticatorTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.dummy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.annotation.Annotation; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.junit.jupiter.api.Test; + +/** + * The two things that keep a mechanism which verifies nothing from taking over + * an endpoint. + */ +class FixedIdentityAuthenticatorTest { + + private static FixedIdentityAuthenticator authenticator(boolean acknowledged, String... roles) { + FixedIdentityAuthenticator authenticator = new FixedIdentityAuthenticator(); + authenticator.activate(new FixedIdentityAuthenticator.Config() { + + @Override + public Class annotationType() { + return FixedIdentityAuthenticator.Config.class; + } + + @Override + public boolean acknowledgeUnauthenticated() { + return acknowledged; + } + + @Override + public String userName() { + return "daanse"; + } + + @Override + public String[] roles() { + return roles; + } + }); + return authenticator; + } + + @Test + void itDoesNotComeUpUntilSomebodySaysTheEndpointIsUnprotected() { + // Installing the bundle used to be enough to name every caller. + assertThatThrownBy(() -> authenticator(false)).isInstanceOf(IllegalStateException.class); + } + + @Test + void itAnswersAsAStandInAndNotAsAnAuthentication() { + // Which is what stops it displacing a real mechanism, displacing a session + // identity, and satisfying a rule that demands a login. + XmlaAuthenticator.Result result = authenticator(true, "Admin").authenticate(XmlaRequest.anonymous()); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Fallback.class); + assertThat(((XmlaAuthenticator.Result.Fallback) result).identity().name()).isEqualTo("daanse"); + } + + @Test + void itOffersNoChallengeBecauseThereIsNothingToAskFor() { + assertThat(authenticator(true).challenge()).isEmpty(); + } + + @Test + void aBlankOrMissingRoleInTheConfigurationIsSkipped() { + XmlaAuthenticator.Result result = authenticator(true, "Admin", " ", null, "Analyst") + .authenticate(XmlaRequest.anonymous()); + + assertThat(((XmlaAuthenticator.Result.Fallback) result).identity().roles()).containsExactlyInAnyOrder("Admin", + "Analyst"); + } +} diff --git a/server/auth.gss/pom.xml b/server/auth.gss/pom.xml new file mode 100644 index 00000000..80432b61 --- /dev/null +++ b/server/auth.gss/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.gss + Daanse XMLA Authentication: Integrated (GSS-API) + Integrated authentication over GSS-API, serving both channels from one + acceptor: the specification's in-band Authenticate handshake and HTTP Negotiate. + Uses the JDK's own GSS implementation, so it adds no dependency. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/GssAcceptor.java b/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/GssAcceptor.java new file mode 100644 index 00000000..0b9d67a9 --- /dev/null +++ b/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/GssAcceptor.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.gss; + +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; + +/** + * The GSS-API acceptor both channels share. + *

+ * A handshake is a sequence of tokens rather than one exchange, so a + * half-finished {@link GSSContext} has to survive between requests. It is kept + * under a key the caller supplies and dropped as soon as it is finished or + * stale, because an abandoned handshake would otherwise hold a context forever. + *

+ * A {@link GSSContext} is not thread safe and holds the state of one handshake, + * so every round on a key runs under that key's lock, and disposal takes the + * same lock - otherwise a context could be disposed while another thread was + * inside {@code acceptSecContext} on it. Which key is safe to use is the + * caller's problem and a real one: see {@link GssAuthenticator}. + */ +final class GssAcceptor { + + /** SPNEGO, as [RFC4178] registers it. */ + static final String SPNEGO_OID = "1.3.6.1.5.5.2"; + + /** Kerberos v5, for a client that names the mechanism directly. */ + static final String KERBEROS_OID = "1.2.840.113554.1.2.2"; + + private final Map pending = new ConcurrentHashMap<>(); + private final GSSManager manager = GSSManager.getInstance(); + private final Duration ttl; + private final String servicePrincipal; + private final int maxPending; + private final AtomicLong lastEviction = new AtomicLong(System.nanoTime()); + + private volatile GSSCredential credential; + + GssAcceptor(String servicePrincipal, Duration ttl, int maxPending) { + this.servicePrincipal = servicePrincipal; + this.ttl = ttl; + this.maxPending = maxPending; + } + + /** + * One round of a handshake that is remembered between requests. + * + * @param key what identifies this handshake across requests + */ + Round accept(String key, byte[] token) throws GSSException { + evictStaleOccasionally(); + Pending handshake = pending.get(key); + if (handshake == null) { + if (pending.size() >= maxPending) { + throw new GSSException(GSSException.UNAVAILABLE, 0, + "too many handshakes are already in flight on this server"); + } + // Built outside computeIfAbsent: acquiring the acceptor credential does JAAS + // and keytab work, which must not run while a map bin is locked. + Pending created = new Pending(newContext()); + handshake = pending.putIfAbsent(key, created); + if (handshake == null) { + handshake = created; + } else { + dispose(created.context); + } + } + + handshake.lock.lock(); + try { + if (handshake.disposed) { + throw new GSSException(GSSException.CONTEXT_EXPIRED, 0, "this handshake was already given up on"); + } + handshake.touch(); + GSSContext context = handshake.context; + byte[] answer = context.acceptSecContext(token, 0, token.length); + if (!context.isEstablished()) { + return new Round(false, answer == null ? new byte[0] : answer, null); + } + String name = context.getSrcName().toString(); + pending.remove(key, handshake); + handshake.disposed = true; + dispose(context); + return new Round(true, answer == null ? new byte[0] : answer, name); + } finally { + handshake.lock.unlock(); + } + } + + /** + * One round of a handshake that is not remembered. + *

+ * For a channel with no key that identifies a caller. A mechanism that needs + * more than one round cannot be served this way, which is the point: sharing a + * context under a key that does not identify one caller is worse than refusing. + */ + Round acceptOnce(byte[] token) throws GSSException { + GSSContext context = newContext(); + try { + byte[] answer = context.acceptSecContext(token, 0, token.length); + if (!context.isEstablished()) { + return new Round(false, answer == null ? new byte[0] : answer, null); + } + return new Round(true, answer == null ? new byte[0] : answer, context.getSrcName().toString()); + } finally { + dispose(context); + } + } + + /** Drops a handshake that will not be continued. */ + void forget(String key) { + Pending abandoned = pending.remove(key); + if (abandoned != null) { + abandoned.lock.lock(); + try { + if (!abandoned.disposed) { + abandoned.disposed = true; + dispose(abandoned.context); + } + } finally { + abandoned.lock.unlock(); + } + } + } + + void dispose() { + for (String key : Map.copyOf(pending).keySet()) { + forget(key); + } + GSSCredential held = credential; + if (held != null) { + credential = null; + try { + held.dispose(); + } catch (GSSException ignored) { + // disposing a credential that is already gone is not a failure + } + } + } + + /** + * @throws GSSException rather than an unchecked exception, so that a JDK with + * no keytab refuses the request instead of failing the + * whole exchange with a server error + */ + private GSSContext newContext() throws GSSException { + return manager.createContext(acceptorCredential()); + } + + /** + * The credential this server accepts with. Without a configured service + * principal the default acceptor credential is used, which is what a JDK + * configured through {@code java.security.auth.login.config} and a keytab + * offers. + *

+ * It is re-acquired once it has expired: the lifetime is the KDC's to decide, + * not this server's to assume, and a keytab may be rotated under a running + * process. + */ + private GSSCredential acceptorCredential() throws GSSException { + GSSCredential held = credential; + if (held != null && isUsable(held)) { + return held; + } + synchronized (this) { + if (credential == null || !isUsable(credential)) { + GSSName name = servicePrincipal == null || servicePrincipal.isBlank() ? null + : manager.createName(servicePrincipal, GSSName.NT_HOSTBASED_SERVICE); + credential = manager.createCredential(name, GSSCredential.DEFAULT_LIFETIME, + new Oid[] { new Oid(SPNEGO_OID), new Oid(KERBEROS_OID) }, GSSCredential.ACCEPT_ONLY); + } + return credential; + } + } + + private static boolean isUsable(GSSCredential held) { + try { + return held.getRemainingLifetime() > 0; + } catch (GSSException gone) { + return false; + } + } + + /** + * Acquires the credential now, so a misconfigured keytab fails the component. + */ + void verifyCredential() throws GSSException { + acceptorCredential(); + } + + /** + * Sweeps at most twice per handshake lifetime rather than on every round - the + * sweep is proportional to the number of handshakes in flight, and running it + * per request turns a busy server quadratic. + */ + private void evictStaleOccasionally() { + long now = System.nanoTime(); + long previous = lastEviction.get(); + long interval = Math.max(ttl.toNanos() / 2, 1); + if (now - previous < interval || !lastEviction.compareAndSet(previous, now)) { + return; + } + Instant deadline = Instant.now().minus(ttl); + for (Iterator> entries = pending.entrySet().iterator(); entries.hasNext();) { + Map.Entry entry = entries.next(); + if (entry.getValue().lastTouched.isBefore(deadline)) { + forget(entry.getKey()); + } + } + } + + private static void dispose(GSSContext context) { + try { + context.dispose(); + } catch (GSSException ignored) { + // a context that cannot be disposed is already unusable + } + } + + /** What one round produced. */ + record Round(boolean established, byte[] token, String name) { + } + + private static final class Pending { + + private final GSSContext context; + private final ReentrantLock lock = new ReentrantLock(); + private volatile Instant lastTouched = Instant.now(); + private volatile boolean disposed; + + private Pending(GSSContext context) { + this.context = context; + } + + /** Refreshed each round, so a slow handshake is not swept out mid-flight. */ + private void touch() { + lastTouched = Instant.now(); + } + } +} diff --git a/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/GssAuthenticator.java b/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/GssAuthenticator.java new file mode 100644 index 00000000..82f64092 --- /dev/null +++ b/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/GssAuthenticator.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.gss; + +import java.security.Principal; +import java.time.Duration; +import java.util.Base64; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.AuthRanking; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.InbandAuthenticator; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.RoleResolution; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.ietf.jgss.GSSException; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Integrated authentication, on both channels, from one acceptor. + *

+ * The specification's in-band {@code Authenticate} is GSS-API ([MS-SSAS] 3.2.2, + * [RFC4178]): tokens are exchanged until GSS reports completion. HTTP + * {@code Negotiate} is the same exchange with the tokens in headers instead of + * in the SOAP body. Both arrive here, so a deployment configures its service + * principal once and Excel, SSMS and a browser all get the same answer. + *

+ * The two channels differ in what identifies a handshake across requests, and + * that difference decides what each can do. The in-band one is keyed by the + * session, which this server issued and which belongs to one caller - so a + * handshake of any length is safe there, and that is where the specification + * puts multi-round exchanges anyway. + *

+ * HTTP Negotiate has no such key. The peer address is not one: everyone behind + * a NAT, a reverse proxy or a terminal server shares it, and a remembered + * context under a shared key can be completed by the wrong caller's rounds, + * which ends with this server naming the wrong person. So HTTP Negotiate + * remembers nothing and gets exactly one round. Kerberos completes in one + * round, which is the case this serves. A deployment that needs several may + * enable {@code allowMultiRoundNegotiate} and accept the peer address as the + * key. + *

+ * GSS establishes a name and nothing else. The roles come from + * {@link RoleResolution}, keyed by that name. + */ +@Component(service = { InbandAuthenticator.class, XmlaAuthenticator.class }, property = "service.ranking:Integer=" + + AuthRanking.NEGOTIATE) +@Designate(ocd = GssAuthenticator.Config.class) +public class GssAuthenticator implements InbandAuthenticator, XmlaAuthenticator { + + private static final String SCHEME = "Negotiate"; + private static final String PREFIX = SCHEME + " "; + + private static final Logger LOGGER = LoggerFactory.getLogger(GssAuthenticator.class); + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + RoleResolution roles; + + volatile GssAcceptor acceptor; + private volatile boolean multiRoundOverHttp; + + @ObjectClassDefinition + @interface Config { + + /** + * The service principal this server accepts for, host-based, e.g. + * {@code HTTP@bi.example.org}. Empty takes whatever the JDK's login + * configuration and keytab provide. + */ + String servicePrincipal() default ""; + + /** + * How long a half-finished handshake is kept before it is dropped, in seconds. + */ + int handshakeTimeoutSeconds() default 60; + + /** How many handshakes may be in flight before further ones are refused. */ + int maxPendingHandshakes() default 1000; + + /** + * Whether an HTTP Negotiate handshake may span several requests, keyed by the + * caller's address. + *

+ * Off, because that key does not identify a caller: behind any shared address + * two callers' rounds meet in one context, and the established name may be the + * wrong one. Turning it on trades that for NTLM support over HTTP. + */ + boolean allowMultiRoundNegotiate() default false; + } + + @Activate + void activate(Config config) throws GSSException { + GssAcceptor created = new GssAcceptor(config.servicePrincipal(), + Duration.ofSeconds(config.handshakeTimeoutSeconds()), config.maxPendingHandshakes()); + // Fail the component rather than every request: a missing keytab is a + // deployment mistake, and it should be visible where deployment mistakes are. + created.verifyCredential(); + multiRoundOverHttp = config.allowMultiRoundNegotiate(); + acceptor = created; + if (multiRoundOverHttp) { + LOGGER.warn("multi-round Negotiate over HTTP is keyed by the caller's address; behind a NAT or a " + + "reverse proxy two callers can share one handshake"); + } + } + + @Deactivate + void deactivate() { + GssAcceptor held = acceptor; + if (held != null) { + held.dispose(); + } + } + + // --- the in-band Authenticate handshake --- + + @Override + public InbandAuthenticator.Result authenticate(byte[] token, XmlaRequest request) { + String key = request.sessionId(); + try { + GssAcceptor.Round round = acceptor.accept(key, token); + if (!round.established()) { + return new InbandAuthenticator.Result.Continue(round.token()); + } + return new InbandAuthenticator.Result.Done(identityOf(round.name()), round.token()); + } catch (GSSException | RuntimeException e) { + acceptor.forget(key); + LOGGER.debug("the in-band handshake failed", e); + return new InbandAuthenticator.Result.Refused("the security token was not accepted"); + } + } + + // --- HTTP Negotiate --- + + @Override + public String scheme() { + return SCHEME; + } + + @Override + public String challenge() { + return SCHEME; + } + + @Override + public XmlaAuthenticator.Result authenticate(XmlaRequest request) { + String authorization = request.header("Authorization"); + if (authorization == null || !authorization.regionMatches(true, 0, SCHEME, 0, SCHEME.length())) { + return new XmlaAuthenticator.Result.NotMine(); + } + String encoded = authorization.length() > PREFIX.length() ? authorization.substring(PREFIX.length()).trim() + : ""; + if (encoded.isEmpty()) { + // The bare scheme is how some clients ask what this server supports. + return new XmlaAuthenticator.Result.Challenge(SCHEME); + } + byte[] token; + try { + token = Base64.getMimeDecoder().decode(encoded); + } catch (IllegalArgumentException notBase64) { + return new XmlaAuthenticator.Result.Refused("the Negotiate token is not base64"); + } + return multiRoundOverHttp ? remembered(request, token) : single(token); + } + + /** One round and nothing kept, which is all Kerberos needs. */ + private XmlaAuthenticator.Result single(byte[] token) { + try { + GssAcceptor.Round round = acceptor.acceptOnce(token); + if (!round.established()) { + return new XmlaAuthenticator.Result.Refused("this endpoint completes Negotiate in one round; " + + "a handshake that needs more belongs in the in-band Authenticate exchange"); + } + return established(round); + } catch (GSSException | RuntimeException e) { + LOGGER.debug("the Negotiate handshake failed", e); + return new XmlaAuthenticator.Result.Refused("the security token was not accepted"); + } + } + + private XmlaAuthenticator.Result remembered(XmlaRequest request, byte[] token) { + String key = request.remoteAddress() == null ? "unknown-peer" : request.remoteAddress(); + try { + GssAcceptor.Round round = acceptor.accept(key, token); + if (!round.established()) { + return new XmlaAuthenticator.Result.Challenge( + PREFIX + Base64.getEncoder().encodeToString(round.token())); + } + return established(round); + } catch (GSSException | RuntimeException e) { + acceptor.forget(key); + LOGGER.debug("the Negotiate handshake failed", e); + return new XmlaAuthenticator.Result.Refused("the security token was not accepted"); + } + } + + /** + * The final token travels back on the successful response: a client that asked + * for mutual authentication verifies it, and without it that client never + * finishes. + */ + private XmlaAuthenticator.Result established(GssAcceptor.Round round) { + byte[] token = round.token(); + String answer = token == null || token.length == 0 ? null : PREFIX + Base64.getEncoder().encodeToString(token); + return new XmlaAuthenticator.Result.Authenticated(identityOf(round.name()), answer); + } + + private AuthenticatedIdentity identityOf(String name) { + Principal principal = new NamedPrincipal(name); + Claims claims = Claims.in(AuthClaims.NS_GSS).put(AuthClaims.SUBJECT, name).build(); + return new AuthenticatedIdentity(principal, roles.resolve(principal, claims), claims); + } +} diff --git a/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/package-info.java b/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/package-info.java new file mode 100644 index 00000000..cf0ec1c8 --- /dev/null +++ b/server/auth.gss/src/main/java/org/eclipse/daanse/xmla/server/auth/gss/package-info.java @@ -0,0 +1,26 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * Integrated authentication: Kerberos and SPNEGO, on both channels. + *

+ * {@code GssAuthenticator} serves the specification's in-band + * {@code Authenticate} handshake and HTTP {@code Negotiate} from one + * {@code GssAcceptor}, so a deployment configures its service principal once. + * The two channels differ in what may identify a handshake across requests, and + * that is why only the in-band one may span several rounds. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for how the established name + * becomes roles. + */ +package org.eclipse.daanse.xmla.server.auth.gss; diff --git a/server/auth.gss/src/test/java/org/eclipse/daanse/xmla/server/auth/gss/GssAuthenticatorTest.java b/server/auth.gss/src/test/java/org/eclipse/daanse/xmla/server/auth/gss/GssAuthenticatorTest.java new file mode 100644 index 00000000..ea348921 --- /dev/null +++ b/server/auth.gss/src/test/java/org/eclipse/daanse/xmla/server/auth/gss/GssAuthenticatorTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.gss; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.InbandAuthenticator; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * What this mechanism decides before GSS-API is involved at all, and what it + * does when GSS-API cannot help it. + *

+ * A real handshake needs a KDC and a keytab, so the exchange itself is not + * exercised here. What is exercised is everything around it - which is where + * the defects were. + */ +class GssAuthenticatorTest { + + private GssAuthenticator authenticator; + + @BeforeEach + void wire() { + authenticator = new GssAuthenticator(); + authenticator.roles = (principal, claims, external) -> Set.of(); + // No keytab and a principal nothing can resolve: every attempt to acquire a + // credential fails, which is the state a misconfigured deployment is in. + authenticator.acceptor = new GssAcceptor("HTTP@nothing.invalid", Duration.ofSeconds(60), 1000); + } + + private static XmlaRequest with(String authorization) { + Map> headers = authorization == null ? Map.of() + : Map.of("Authorization", List.of(authorization)); + return new XmlaRequest(null, null, headers, null, "127.0.0.1"); + } + + @Test + void aRequestWithoutTheSchemeIsNotThisMechanismsBusiness() { + assertThat(authenticator.authenticate(with(null))).isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + assertThat(authenticator.authenticate(with("Basic abc"))).isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + @Test + void theBareSchemeIsAnsweredWithTheChallenge() { + // Some clients send it to ask what the server supports; falling through to + // anonymous left them nothing to go on. + XmlaAuthenticator.Result result = authenticator.authenticate(with("Negotiate")); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Challenge.class); + assertThat(((XmlaAuthenticator.Result.Challenge) result).headerValue()).isEqualTo("Negotiate"); + } + + @Test + void aTokenThatIsNotBase64IsRefused() { + assertThat(authenticator.authenticate(with("Negotiate ~~~not base64~~~"))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void withoutAUsableCredentialTheRequestIsRefusedRatherThanFailing() { + // This used to leave an IllegalStateException that no handler caught, so a + // deployment with no keytab answered every request with a server error. + String token = Base64.getEncoder().encodeToString(new byte[] { 1, 2, 3, 4 }); + + assertThat(authenticator.authenticate(with("Negotiate " + token))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void theInBandHandshakeIsRefusedTheSameWay() { + InbandAuthenticator.Result result = authenticator.authenticate(new byte[] { 1, 2, 3, 4 }, + XmlaRequest.anonymous().withSession("a-session")); + + assertThat(result).isInstanceOf(InbandAuthenticator.Result.Refused.class); + } + + @Test + void theSchemeIsMatchedWithoutRegardToCase() { + assertThat(authenticator.authenticate(with("negotiate"))) + .isInstanceOf(XmlaAuthenticator.Result.Challenge.class); + } + + @Test + void theSchemeAndTheChallengeAreTheSameWord() { + // They used to be three independent spellings of one protocol token. + assertThat(authenticator.challenge()).isEqualTo(authenticator.scheme()); + } +} diff --git a/server/auth.header/pom.xml b/server/auth.header/pom.xml new file mode 100644 index 00000000..bcacfbbc --- /dev/null +++ b/server/auth.header/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.header + Daanse XMLA Authentication: Trusted Proxy Header + Identity forwarded by a reverse proxy that already authenticated the caller, + as an XmlaAuthenticator service, restricted to the upstream addresses a deployment + declares trustworthy. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/PeerMatcher.java b/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/PeerMatcher.java new file mode 100644 index 00000000..84b7c60f --- /dev/null +++ b/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/PeerMatcher.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.header; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; + +/** + * Whether a request came from an address a deployment named. + *

+ * Comparing the strings does not work. A container reports loopback as + * {@code 0:0:0:0:0:0:0:1} while an operator writes {@code ::1}; a dual-stack + * socket reports {@code ::ffff:10.0.0.5} for a peer the operator wrote as + * {@code 10.0.0.5}; a link-local address carries a zone suffix. Each of those + * silently makes a trusted front untrusted, and the failure is invisible - the + * request just falls through to anonymous. So addresses are compared as + * addresses, and a range may be written as one. + */ +final class PeerMatcher { + + private final List entries; + + private record Entry(byte[] address, int prefixBits) { + + boolean matches(byte[] peer) { + if (peer.length != address.length) { + return false; + } + int whole = prefixBits / 8; + for (int index = 0; index < whole; index++) { + if (peer[index] != address[index]) { + return false; + } + } + int remaining = prefixBits % 8; + if (remaining == 0) { + return true; + } + int mask = 0xFF << (8 - remaining); + return (peer[whole] & mask) == (address[whole] & mask); + } + } + + private PeerMatcher(List entries) { + this.entries = entries; + } + + /** + * @param configured addresses, host names, or CIDR ranges such as + * {@code 10.0.0.0/8} + * @throws IllegalArgumentException if an entry cannot be resolved, so a typo + * fails the component rather than quietly + * trusting nobody + */ + static PeerMatcher of(String[] configured) { + List entries = new ArrayList<>(); + for (String entry : configured) { + if (entry == null || entry.isBlank()) { + continue; + } + entries.add(parse(entry.trim())); + } + return new PeerMatcher(List.copyOf(entries)); + } + + private static Entry parse(String entry) { + int slash = entry.lastIndexOf('/'); + String host = slash < 0 ? entry : entry.substring(0, slash); + InetAddress address = resolve(host); + byte[] bytes = address.getAddress(); + if (slash < 0) { + return new Entry(bytes, bytes.length * 8); + } + int prefix; + try { + prefix = Integer.parseInt(entry.substring(slash + 1).trim()); + } catch (NumberFormatException notANumber) { + throw new IllegalArgumentException("the trusted upstream '" + entry + "' has no readable prefix length"); + } + if (prefix < 0 || prefix > bytes.length * 8) { + throw new IllegalArgumentException("the trusted upstream '" + entry + "' has a prefix length outside " + + "what its address family allows"); + } + return new Entry(bytes, prefix); + } + + private static InetAddress resolve(String host) { + try { + return InetAddress.getByName(stripZone(host)); + } catch (UnknownHostException unresolvable) { + throw new IllegalArgumentException("the trusted upstream '" + host + "' cannot be resolved", unresolvable); + } + } + + /** A zone suffix names an interface on this host and cannot identify a peer. */ + private static String stripZone(String host) { + int zone = host.indexOf('%'); + return zone < 0 ? host : host.substring(0, zone); + } + + boolean isEmpty() { + return entries.isEmpty(); + } + + boolean matches(String peer) { + if (peer == null || peer.isBlank() || entries.isEmpty()) { + return false; + } + InetAddress address; + try { + address = InetAddress.getByName(stripZone(peer.trim())); + } catch (UnknownHostException unreadable) { + return false; + } + byte[] bytes = address.getAddress(); + for (Entry entry : entries) { + if (entry.matches(bytes) || entry.matches(alternateFamily(bytes))) { + return true; + } + } + return false; + } + + /** + * The same address in the other family, so an IPv4-mapped peer on a dual-stack + * socket matches an entry written as plain IPv4. + * + * @return an empty array when there is no equivalent, which matches nothing + */ + private static byte[] alternateFamily(byte[] address) { + if (address.length == 16 && isV4Mapped(address)) { + return new byte[] { address[12], address[13], address[14], address[15] }; + } + if (address.length == 4) { + byte[] mapped = new byte[16]; + mapped[10] = (byte) 0xFF; + mapped[11] = (byte) 0xFF; + System.arraycopy(address, 0, mapped, 12, 4); + return mapped; + } + return new byte[0]; + } + + private static boolean isV4Mapped(byte[] address) { + for (int index = 0; index < 10; index++) { + if (address[index] != 0) { + return false; + } + } + return address[10] == (byte) 0xFF && address[11] == (byte) 0xFF; + } +} diff --git a/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/TrustedHeaderAuthenticator.java b/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/TrustedHeaderAuthenticator.java new file mode 100644 index 00000000..59daae1f --- /dev/null +++ b/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/TrustedHeaderAuthenticator.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.header; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.Principal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.AuthRanking; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.RoleResolution; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Identity forwarded by a front that already authenticated the caller. + *

+ * Authelia, oauth2-proxy and their kind sit in front of an endpoint, do the + * login - OIDC, LDAP, WebAuthn, their business - and forward who it was in + * headers. This turns those headers into the identity, which is how a + * deployment gets OIDC without this server speaking it. + *

+ * A forwarded header is only worth believing if it cannot have come from the + * client, so this asks for proof and refuses to come up with none configured. + * Two proofs are available and each configured one must hold: + *

    + *
  • the peer address is one of {@code trustedUpstreams}. The + * strongest check where it is available, because it cannot be replayed - but it + * is available only where this server sees the front directly.
  • + *
  • a shared secret arrives in a header only the front + * knows. Independent of the transport and of any address rewriting, and it also + * covers the case the address check cannot: a front that forgets to strip the + * identity headers from what reaches it.
  • + *
+ *

+ * Which to use depends on what sits in front. When this server + * accepts the front's connection itself, the address is enough. When a servlet + * container processes {@code Forwarded}/{@code X-Forwarded-For} before the + * request arrives - Tomcat's {@code RemoteIpValve}, Jetty's + * {@code ForwardedRequestCustomizer} - the reported peer is the original client + * and the socket peer is simply gone; there the shared secret is the only proof + * that works, and configuring the address instead would refuse every request. + *

+ * The groups and anything else the front forwards are claims, not roles: they + * are the front's vocabulary, and the mapping behind {@link RoleResolution} + * translates them. + */ +@Component(service = XmlaAuthenticator.class, configurationPolicy = ConfigurationPolicy.REQUIRE, + property = "service.ranking:Integer=" + AuthRanking.TRUSTED_HEADER) +@Designate(ocd = TrustedHeaderAuthenticator.Config.class) +public class TrustedHeaderAuthenticator implements XmlaAuthenticator { + + private static final Logger LOGGER = LoggerFactory.getLogger(TrustedHeaderAuthenticator.class); + + /** How often the refusals are summarised, so a caller cannot flood the log. */ + private static final long REPORT_INTERVAL_NANOS = 60_000_000_000L; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + RoleResolution roles; + + private volatile Settings settings; + + private final Refusals refusals = new Refusals(); + + /** Read as one, so a reconfiguration cannot be seen half-applied. */ + private record Settings(String userHeader, String groupsHeader, Map claimHeaders, + PeerMatcher trusted, String secretHeader, byte[] secret) { + + private boolean checksPeer() { + return !trusted.isEmpty(); + } + + private boolean checksSecret() { + return secret.length > 0; + } + } + + @ObjectClassDefinition + @interface Config { + + /** The header the front puts the user name in. */ + String userHeader() default "Remote-User"; + + /** The header the front puts the comma-separated groups in. */ + String groupsHeader() default "Remote-Groups"; + + /** + * Anything else worth keeping, as {@code Header=claim}, e.g. + * {@code Remote-Email=email}. The values become claims of this mechanism and are + * available to a role mapping; they grant nothing by themselves. + */ + String[] claimHeaders() default {}; + + /** + * The addresses whose forwarded headers are believed - an address, a host name, + * or a range such as {@code 10.0.0.0/8}. + *

+ * Leave empty where the peer address is not the front's: behind a servlet + * container that processes forwarded headers it is the client's, and no value + * configured here could ever match. + */ + String[] trustedUpstreams() default {}; + + /** + * The header carrying the shared secret, e.g. {@code X-Forwarded-Auth}. Empty + * switches this proof off. + */ + String sharedSecretHeader() default ""; + + /** + * The value that header must carry. It is only as good as the front keeping it + * to itself, so it belongs in the same place as any other credential. + */ + String sharedSecret() default ""; + } + + @Activate + void activate(Config config) { + if (config.userHeader() == null || config.userHeader().isBlank()) { + throw new IllegalStateException("userHeader names the header carrying the identity and cannot be empty"); + } + PeerMatcher trusted = PeerMatcher.of(config.trustedUpstreams()); + byte[] secret = secretOf(config); + if (trusted.isEmpty() && secret.length == 0) { + throw new IllegalStateException("nothing would prove a forwarded identity came from the front: configure " + + "trustedUpstreams, or sharedSecretHeader and sharedSecret where the peer address is not the " + + "front's - this mechanism does not come up believing anybody who asks"); + } + settings = new Settings(config.userHeader(), config.groupsHeader(), claimHeadersOf(config), trusted, + config.sharedSecretHeader(), secret); + if (!trusted.isEmpty() && secret.length == 0) { + LOGGER.info("forwarded identity is believed from {} configured upstream(s) on the peer address alone; " + + "a shared secret would also cover a front that fails to strip the headers", + config.trustedUpstreams().length); + } + } + + private static byte[] secretOf(Config config) { + boolean named = config.sharedSecretHeader() != null && !config.sharedSecretHeader().isBlank(); + boolean valued = config.sharedSecret() != null && !config.sharedSecret().isBlank(); + if (named != valued) { + throw new IllegalStateException("sharedSecretHeader and sharedSecret go together; one without the other " + + "would either check nothing or check against nothing"); + } + return valued ? config.sharedSecret().getBytes(StandardCharsets.UTF_8) : new byte[0]; + } + + private static Map claimHeadersOf(Config config) { + Map named = new LinkedHashMap<>(); + for (String entry : config.claimHeaders()) { + if (entry == null || entry.isBlank()) { + continue; + } + int separator = entry.indexOf('='); + if (separator <= 0 || separator == entry.length() - 1) { + throw new IllegalStateException("the claim header '" + entry + "' is not 'Header=claim'"); + } + named.put(entry.substring(0, separator).trim(), entry.substring(separator + 1).trim()); + } + return Map.copyOf(named); + } + + @Override + public String scheme() { + return "TrustedHeader"; + } + + @Override + public String challenge() { + // A proxy identity cannot be asked for; the challenge, if any, is the front's. + return ""; + } + + @Override + public Result authenticate(XmlaRequest request) { + Settings current = settings; + String name = request.header(current.userHeader()); + if (name == null || name.isBlank()) { + return new Result.NotMine(); + } + if (!proven(current, request)) { + // Somebody sent a forwarded identity this endpoint will not believe. Worth + // knowing about, but the request is the caller's to shape, so it is summarised + // rather than logged per request. + refusals.record(request.remoteAddress()); + return new Result.NotMine(); + } + String user = name.trim(); + if (user.indexOf('\r') >= 0 || user.indexOf('\n') >= 0) { + return new Result.Refused("the forwarded user name contains a line break"); + } + + List groups = split(request.header(current.groupsHeader())); + Claims claims = claimsOf(current, request, groups); + Principal principal = new NamedPrincipal(user); + + Set granted = roles.resolve(principal, claims, groups); + return Result.Authenticated.of(new AuthenticatedIdentity(principal, granted, claims)); + } + + /** Every configured proof has to hold; at least one is configured by construction. */ + private static boolean proven(Settings current, XmlaRequest request) { + if (current.checksPeer() && !current.trusted().matches(request.remoteAddress())) { + return false; + } + return !current.checksSecret() || carriesSecret(current, request); + } + + private static boolean carriesSecret(Settings current, XmlaRequest request) { + String presented = request.header(current.secretHeader()); + if (presented == null) { + return false; + } + // Time-constant: a comparison that stops at the first wrong byte tells whoever + // can measure it how much of a guess was right. + return MessageDigest.isEqual(presented.trim().getBytes(StandardCharsets.UTF_8), current.secret()); + } + + private static Claims claimsOf(Settings current, XmlaRequest request, List groups) { + Claims.Builder claims = Claims.in(AuthClaims.NS_HEADER).put(AuthClaims.GROUPS, groups); + for (Map.Entry named : current.claimHeaders().entrySet()) { + String value = request.header(named.getKey()); + if (value != null && !value.isBlank()) { + claims.put(named.getValue(), value.trim()); + } + } + return claims.build(); + } + + private static List split(String value) { + List parts = new ArrayList<>(); + if (value == null) { + return parts; + } + for (String part : value.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + parts.add(trimmed); + } + } + return parts; + } + + /** + * Counts refused forwarded identities and reports them at most once a minute. + *

+ * Per request this would be a log flood anybody could trigger by sending one + * header; never reporting it would hide a misconfigured front, which looks + * exactly like "the login silently stopped working". + */ + private static final class Refusals { + + private final java.util.concurrent.atomic.AtomicLong since = + new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); + private final java.util.concurrent.atomic.AtomicLong count = + new java.util.concurrent.atomic.AtomicLong(); + private volatile String last; + + private void record(String peer) { + last = peer; + count.incrementAndGet(); + LOGGER.debug("ignoring a forwarded identity from {}: nothing proves it came from the front", peer); + + long now = System.nanoTime(); + long started = since.get(); + if (now - started < REPORT_INTERVAL_NANOS || !since.compareAndSet(started, now)) { + return; + } + long ignored = count.getAndSet(0); + if (ignored > 0) { + LOGGER.warn("{} forwarded identities were ignored in the last minute because nothing proved they " + + "came from the front; the last was from {}", ignored, last); + } + } + } +} diff --git a/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/package-info.java b/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/package-info.java new file mode 100644 index 00000000..b9d682ae --- /dev/null +++ b/server/auth.header/src/main/java/org/eclipse/daanse/xmla/server/auth/header/package-info.java @@ -0,0 +1,27 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * Identity forwarded by a front that already authenticated the caller. + *

+ * {@code TrustedHeaderAuthenticator} turns the headers Authelia, oauth2-proxy + * and their kind forward into the identity, and asks for proof that they came + * from the front: the peer address, matched by {@code PeerMatcher}, or a shared + * secret only the front knows, or both. The address is unavailable behind a + * container that has already processed forwarded headers; the secret also covers + * the case the address cannot, namely a front that fails to strip the identity + * headers from what reaches it. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for how the forwarded groups become catalog roles. + */ +package org.eclipse.daanse.xmla.server.auth.header; diff --git a/server/auth.header/src/test/java/org/eclipse/daanse/xmla/server/auth/header/TrustedHeaderAuthenticatorTest.java b/server/auth.header/src/test/java/org/eclipse/daanse/xmla/server/auth/header/TrustedHeaderAuthenticatorTest.java new file mode 100644 index 00000000..f75ea5b7 --- /dev/null +++ b/server/auth.header/src/test/java/org/eclipse/daanse/xmla/server/auth/header/TrustedHeaderAuthenticatorTest.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.header; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.junit.jupiter.api.Test; + +/** + * What proves that a forwarded identity came from the front, and what becomes + * of it once it is believed. + *

+ * The two proofs exist for different deployments: the peer address where this + * server accepts the front's connection itself, the shared secret where a + * servlet container has already rewritten the peer to the original client and + * the address can never match. + */ +class TrustedHeaderAuthenticatorTest { + + private final List> handedIn = new ArrayList<>(); + + /** Mutable so each test states only what it cares about. */ + private static final class Given { + + private String[] upstreams = new String[0]; + private String secretHeader = ""; + private String secret = ""; + private String[] claimHeaders = new String[0]; + + private Given trusting(String... addresses) { + upstreams = addresses; + return this; + } + + private Given withSecret(String header, String value) { + secretHeader = header; + secret = value; + return this; + } + + private Given carrying(String... headers) { + claimHeaders = headers; + return this; + } + } + + private TrustedHeaderAuthenticator authenticator(Given given) { + TrustedHeaderAuthenticator authenticator = new TrustedHeaderAuthenticator(); + authenticator.roles = (principal, claims, external) -> { + handedIn.add(external); + return Set.copyOf(external); + }; + authenticator.activate(new TrustedHeaderAuthenticator.Config() { + + @Override + public Class annotationType() { + return TrustedHeaderAuthenticator.Config.class; + } + + @Override + public String userHeader() { + return "Remote-User"; + } + + @Override + public String groupsHeader() { + return "Remote-Groups"; + } + + @Override + public String[] claimHeaders() { + return given.claimHeaders; + } + + @Override + public String[] trustedUpstreams() { + return given.upstreams; + } + + @Override + public String sharedSecretHeader() { + return given.secretHeader; + } + + @Override + public String sharedSecret() { + return given.secret; + } + }); + return authenticator; + } + + /** A request carrying whatever the test names, as {@code Header, value} pairs. */ + private static XmlaRequest from(String peer, String... headers) { + Map> named = new LinkedHashMap<>(); + for (int index = 0; index + 1 < headers.length; index += 2) { + if (headers[index + 1] != null) { + named.put(headers[index], List.of(headers[index + 1])); + } + } + return new XmlaRequest(null, null, named, null, peer); + } + + // --- what has to be configured at all --- + + @Test + void withNoProofAtAllItDoesNotComeUp() { + // It would believe anybody who sends a header, which is the failure this + // mechanism exists to prevent. + assertThatThrownBy(() -> authenticator(new Given())).isInstanceOf(IllegalStateException.class); + } + + @Test + void halfASecretIsRefused() { + assertThatThrownBy(() -> authenticator(new Given().withSecret("X-Auth", ""))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> authenticator(new Given().withSecret("", "s3cret"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void anUpstreamThatCannotBeResolvedStopsTheComponentComingUp() { + // Better than quietly trusting nobody because of a typo. + assertThatThrownBy(() -> authenticator(new Given().trusting("no-such-host.invalid"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> authenticator(new Given().trusting("10.0.0.0/99"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aClaimHeaderThatCannotBeReadStopsTheComponentComingUp() { + assertThatThrownBy(() -> authenticator(new Given().trusting("10.0.0.5").carrying("no-separator"))) + .isInstanceOf(IllegalStateException.class); + } + + // --- the peer address as proof --- + + @Test + void aHeaderFromATrustedFrontIsBelieved() { + XmlaAuthenticator.Result result = authenticator(new Given().trusting("10.0.0.5")) + .authenticate(from("10.0.0.5", "Remote-User", "alice", "Remote-Groups", "bi-admin, bi-eu")); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + assertThat(((XmlaAuthenticator.Result.Authenticated) result).identity().name()).isEqualTo("alice"); + } + + @Test + void theSameHeaderFromAnybodyElseIsNot() { + assertThat(authenticator(new Given().trusting("10.0.0.5")) + .authenticate(from("10.0.0.6", "Remote-User", "alice"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + @Test + void aFrontReportedInAnotherAddressFormStillMatches() { + // A container reports loopback as 0:0:0:0:0:0:0:1 while an operator writes ::1. + assertThat(authenticator(new Given().trusting("::1")) + .authenticate(from("0:0:0:0:0:0:0:1", "Remote-User", "alice"))) + .isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + assertThat(authenticator(new Given().trusting("10.0.0.5")) + .authenticate(from("::ffff:10.0.0.5", "Remote-User", "alice"))) + .isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + } + + @Test + void aRangeMayBeNamedInsteadOfEveryAddress() { + assertThat(authenticator(new Given().trusting("10.0.0.0/8")) + .authenticate(from("10.11.12.13", "Remote-User", "alice"))) + .isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + assertThat(authenticator(new Given().trusting("10.0.0.0/8")) + .authenticate(from("11.0.0.1", "Remote-User", "alice"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + // --- the shared secret as proof --- + + @Test + void behindARewritingContainerTheSecretIsWhatWorks() { + // The reported peer is the original client and no configured address could + // ever match it; the secret is unaffected by that. + TrustedHeaderAuthenticator authenticator = authenticator(new Given().withSecret("X-Forwarded-Auth", "s3cret")); + + XmlaAuthenticator.Result result = authenticator + .authenticate(from("203.0.113.9", "Remote-User", "alice", "X-Forwarded-Auth", "s3cret")); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + } + + @Test + void withoutTheSecretTheIdentityIsIgnored() { + TrustedHeaderAuthenticator authenticator = authenticator(new Given().withSecret("X-Forwarded-Auth", "s3cret")); + + assertThat(authenticator.authenticate(from("203.0.113.9", "Remote-User", "alice"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + assertThat(authenticator.authenticate(from("203.0.113.9", "Remote-User", "alice", "X-Forwarded-Auth", "wrong"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + @Test + void everyConfiguredProofHasToHold() { + // Configuring both is defence in depth, not a choice between them: the address + // covers replay, the secret covers a front that fails to strip the headers. + TrustedHeaderAuthenticator authenticator = authenticator( + new Given().trusting("10.0.0.5").withSecret("X-Forwarded-Auth", "s3cret")); + + assertThat(authenticator + .authenticate(from("10.0.0.5", "Remote-User", "alice", "X-Forwarded-Auth", "s3cret"))) + .isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + assertThat(authenticator.authenticate(from("10.0.0.5", "Remote-User", "alice"))) + .as("right address, no secret").isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + assertThat(authenticator + .authenticate(from("10.0.0.6", "Remote-User", "alice", "X-Forwarded-Auth", "s3cret"))) + .as("right secret, wrong address").isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + @Test + void aSecretOfTheWrongLengthIsRejectedLikeAnyOther() { + TrustedHeaderAuthenticator authenticator = authenticator(new Given().withSecret("X-Forwarded-Auth", "s3cret")); + + assertThat(authenticator.authenticate(from("10.0.0.5", "Remote-User", "alice", "X-Forwarded-Auth", "s"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + assertThat(authenticator + .authenticate(from("10.0.0.5", "Remote-User", "alice", "X-Forwarded-Auth", "s3cretlonger"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + // --- what the believed identity carries --- + + @Test + void theGroupsAreHandedInOnceAndAsClaimsOfThisMechanism() { + XmlaAuthenticator.Result result = authenticator(new Given().trusting("10.0.0.5")) + .authenticate(from("10.0.0.5", "Remote-User", "alice", "Remote-Groups", "bi-admin, bi-eu")); + + assertThat(handedIn).hasSize(1); + assertThat(handedIn.get(0)).containsExactly("bi-admin", "bi-eu"); + assertThat(((XmlaAuthenticator.Result.Authenticated) result).identity().claims() + .all(AuthClaims.NS_HEADER, AuthClaims.GROUPS)).containsExactly("bi-admin", "bi-eu"); + } + + @Test + void whateverElseTheFrontSendsCanBecomeAClaim() { + XmlaAuthenticator.Result result = authenticator( + new Given().trusting("10.0.0.5").carrying("Remote-Email=email", "Remote-Name=name")) + .authenticate(from("10.0.0.5", "Remote-User", "alice", "Remote-Email", "alice@example.org", + "Remote-Name", "Alice Example")); + + var claims = ((XmlaAuthenticator.Result.Authenticated) result).identity().claims(); + assertThat(claims.first(AuthClaims.NS_HEADER, "email")).isEqualTo("alice@example.org"); + assertThat(claims.first(AuthClaims.NS_HEADER, "name")).isEqualTo("Alice Example"); + } + + @Test + void aConfiguredClaimHeaderTheFrontDidNotSendIsSimplyAbsent() { + XmlaAuthenticator.Result result = authenticator( + new Given().trusting("10.0.0.5").carrying("Remote-Email=email")) + .authenticate(from("10.0.0.5", "Remote-User", "alice")); + + assertThat(((XmlaAuthenticator.Result.Authenticated) result).identity().claims() + .first(AuthClaims.NS_HEADER, "email")).isNull(); + } + + @Test + void aNameCarryingALineBreakIsRefused() { + assertThat(authenticator(new Given().trusting("10.0.0.5")) + .authenticate(from("10.0.0.5", "Remote-User", "alice\r\nX-Admin: true"))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void aRequestWithoutTheHeaderIsNotThisMechanismsBusiness() { + assertThat(authenticator(new Given().trusting("10.0.0.5")).authenticate(from("10.0.0.5"))) + .isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } +} diff --git a/server/auth.oidc/pom.xml b/server/auth.oidc/pom.xml new file mode 100644 index 00000000..2730b9df --- /dev/null +++ b/server/auth.oidc/pom.xml @@ -0,0 +1,59 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.oidc + Daanse XMLA Authentication: OIDC Bearer + OAuth/OIDC bearer tokens as an XmlaAuthenticator service: the token is + validated in process against the issuer's published keys, and its claims become the + caller's identity. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + com.nimbusds + nimbus-jose-jwt + 9.30.2 + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.oidc/src/main/java/org/eclipse/daanse/xmla/server/auth/oidc/BearerAuthenticator.java b/server/auth.oidc/src/main/java/org/eclipse/daanse/xmla/server/auth/oidc/BearerAuthenticator.java new file mode 100644 index 00000000..c3fefdcd --- /dev/null +++ b/server/auth.oidc/src/main/java/org/eclipse/daanse/xmla/server/auth/oidc/BearerAuthenticator.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.oidc; + +import java.net.URI; +import java.security.Principal; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.AuthRanking; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.RoleResolution; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; + +/** + * OAuth/OIDC bearer tokens, validated here rather than taken on trust. + *

+ * The token's signature is checked against the keys the issuer publishes, which + * are fetched from the configured JWKS endpoint and cached, and its + * {@code iss}, {@code aud} and expiry are checked before anything else happens. + * A deployment that would rather have a reverse proxy do this can use the + * trusted-header mechanism instead; both are the same shape to the rest of the + * server. + *

+ * The token's own claims become the caller's {@link Claims}, so the groups an + * identity provider ships are available to a + * {@link org.eclipse.daanse.xmla.api.auth.RoleMapping} without a second lookup. A + * deployment that keeps its roles elsewhere registers a + * {@link org.eclipse.daanse.xmla.api.auth.RoleProvider}, and both sources are used. + */ +@Component(service = XmlaAuthenticator.class, configurationPolicy = ConfigurationPolicy.REQUIRE, property = "service.ranking:Integer=" + + AuthRanking.BEARER) +@Designate(ocd = BearerAuthenticator.Config.class) +public class BearerAuthenticator implements XmlaAuthenticator { + + private static final String SCHEME = "Bearer"; + private static final String PREFIX = SCHEME + " "; + + /** + * Enough for any real key set, small enough that a wrong URL cannot fill the + * heap. + */ + private static final int DEFAULT_JWKS_SIZE_LIMIT = 51_200; + + private static final Logger LOGGER = LoggerFactory.getLogger(BearerAuthenticator.class); + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + RoleResolution roles; + + private volatile DefaultJWTProcessor processor; + private volatile String principalClaim; + private volatile List groupsClaims; + + @ObjectClassDefinition + @interface Config { + + /** The issuer a token must name in {@code iss}. */ + String issuer(); + + /** Where the issuer publishes its signing keys. */ + String jwksUri(); + + /** + * The audiences a token may name in {@code aud}; naming one of them is enough. + * Empty accepts any, which is only right when the issuer serves this endpoint + * alone. + */ + String[] audiences() default {}; + + /** The signature algorithms accepted; a token signed otherwise is refused. */ + String[] algorithms() default { "RS256" }; + + /** + * The {@code typ} values accepted, so an ID token cannot be presented where an + * access token was meant. + */ + String[] tokenTypes() default { "at+jwt", "JWT" }; + + /** + * The claim the caller's name is taken from. + *

+ * {@code sub} by default. OIDC guarantees stability and uniqueness only for + * that one; {@code preferred_username} is documented as changeable, and it is + * also the key a role provider looks the caller up by - so allowing it to + * change is allowing roles to be inherited. + */ + String principalClaim() default "sub"; + + /** + * The claims whose values are the caller's groups. Their union goes through the + * role mapping. + */ + String[] groupsClaims() default { "groups", "roles" }; + + /** How much clock difference between issuer and this server is tolerated. */ + int clockSkewSeconds() default 60; + + /** How long to wait for the key set, in milliseconds. */ + int jwksTimeoutMillis() default 2000; + + /** + * Whether a plaintext {@code jwksUri} is allowed. It is not by default: anyone + * on the path could substitute the signing keys and mint accepted tokens. + */ + boolean allowInsecureJwks() default false; + } + + @Activate + void activate(Config config) throws Exception { + URI jwks = validated(config); + + DefaultJWTProcessor built = new DefaultJWTProcessor<>(); + built.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithmsOf(config), keySource(jwks, config))); + built.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(typesOf(config))); + + Set required = new LinkedHashSet<>(List.of("sub", "iat", "exp")); + JWTClaimsSet expected = new JWTClaimsSet.Builder().issuer(config.issuer()).build(); + // The four-argument form is the one that checks aud by containment. The shorter + // one compares the whole list, so a token naming this endpoint among others - + // which is what Keycloak issues by default - would be refused. + Set audiences = new LinkedHashSet<>(List.of(config.audiences())); + DefaultJWTClaimsVerifier verifier = audiences.isEmpty() + ? new DefaultJWTClaimsVerifier<>(expected, required) + : new DefaultJWTClaimsVerifier<>(audiences, expected, required, null); + verifier.setMaxClockSkew(config.clockSkewSeconds()); + built.setJWTClaimsSetVerifier(verifier); + + principalClaim = config.principalClaim(); + groupsClaims = List.of(config.groupsClaims()); + // Published only once it is fully built: a reader that saw it half-configured + // would refuse every token and say nothing about why. + processor = built; + + if (audiences.isEmpty()) { + LOGGER.warn("no audience configured: a token minted for any other service by the same issuer is " + + "accepted here"); + } + } + + private static URI validated(Config config) { + if (config.issuer() == null || config.issuer().isBlank()) { + throw new IllegalStateException("issuer names the only issuer whose tokens are accepted and is required"); + } + if (config.jwksUri() == null || config.jwksUri().isBlank()) { + throw new IllegalStateException("jwksUri points at the issuer's signing keys and is required"); + } + URI jwks = URI.create(config.jwksUri()); + if (!"https".equalsIgnoreCase(jwks.getScheme()) && !config.allowInsecureJwks()) { + throw new IllegalStateException("the signing keys would be fetched over plaintext from " + jwks + + "; anyone on the path could then mint tokens this server accepts"); + } + return jwks; + } + + private static Set algorithmsOf(Config config) { + Set accepted = new LinkedHashSet<>(); + for (String name : config.algorithms()) { + JWSAlgorithm algorithm = JWSAlgorithm.parse(name.trim()); + if (JWSAlgorithm.NONE.equals(algorithm) || JWSAlgorithm.Family.HMAC_SHA.contains(algorithm)) { + // "none" is no signature at all, and a shared HMAC secret is not what a JWKS + // publishes - accepting either turns the key set into decoration. + throw new IllegalStateException( + "the signature algorithm " + name + " cannot be verified against a " + "published key set"); + } + accepted.add(algorithm); + } + if (accepted.isEmpty()) { + throw new IllegalStateException("at least one signature algorithm is required"); + } + return accepted; + } + + private static Set typesOf(Config config) { + Set accepted = new LinkedHashSet<>(); + for (String name : config.tokenTypes()) { + accepted.add(new JOSEObjectType(name.trim())); + } + return accepted; + } + + /** + * Fetched off the request thread, retried, and kept across a brief outage - + * otherwise a key set that is briefly unreachable refuses every caller whose + * key is not already cached. + */ + private static JWKSource keySource(URI jwks, Config config) throws Exception { + int timeout = config.jwksTimeoutMillis(); + return JWKSourceBuilder + .create(jwks.toURL(), new DefaultResourceRetriever(timeout, timeout, DEFAULT_JWKS_SIZE_LIMIT)) + .retrying(true).refreshAheadCache(true).rateLimited(true).build(); + } + + @Deactivate + void deactivate() { + processor = null; + } + + @Override + public String scheme() { + return SCHEME; + } + + @Override + public String challenge() { + return SCHEME; + } + + @Override + public Result authenticate(XmlaRequest request) { + String authorization = request.header("Authorization"); + if (authorization == null || !authorization.regionMatches(true, 0, SCHEME, 0, SCHEME.length())) { + return new Result.NotMine(); + } + String token = authorization.length() > PREFIX.length() ? authorization.substring(PREFIX.length()).trim() : ""; + if (token.isEmpty()) { + return new Result.Challenge(SCHEME); + } + JWTClaimsSet verified; + try { + verified = processor.process(token, null); + } catch (Exception refused) { + // Signature, type, issuer, audience and expiry all end here. The client learns + // that the token was not accepted and not which of the checks it failed. + LOGGER.debug("the bearer token was not accepted", refused); + return new Result.Refused("the bearer token was not accepted"); + } + + String name = nameOf(verified); + if (name == null) { + return new Result.Refused("the bearer token names no subject"); + } + Claims claims = claimsOf(verified); + Principal principal = new NamedPrincipal(name); + + return Result.Authenticated + .of(new AuthenticatedIdentity(principal, roles.resolve(principal, claims, groups(claims)), claims)); + } + + private String nameOf(JWTClaimsSet verified) { + Object named = verified.getClaim(principalClaim); + if (named instanceof String name && !name.isBlank()) { + return name; + } + return verified.getSubject(); + } + + /** + * Every claim the token carries, under this mechanism's namespace. + *

+ * The namespace is what stops a token deciding something it has no business + * deciding: a claim called {@code dn} here becomes {@code jwt:dn} and cannot be + * mistaken for the {@code ldap:dn} a directory bind established, which a role + * provider steers its group lookup by. + */ + private static Claims claimsOf(JWTClaimsSet verified) { + Claims.Builder claims = Claims.in(AuthClaims.NS_JWT); + for (Map.Entry claim : verified.getClaims().entrySet()) { + claims.put(claim.getKey(), stringsOf(claim.getValue())); + } + return claims.build(); + } + + private static List stringsOf(Object value) { + List strings = new ArrayList<>(); + if (value instanceof List list) { + for (Object each : list) { + if (each != null) { + strings.add(asString(each)); + } + } + } else if (value != null) { + strings.add(asString(value)); + } + return strings; + } + + /** + * Times render as epoch seconds; {@code Date.toString} would carry a locale. + */ + private static String asString(Object value) { + return value instanceof Date date ? Long.toString(date.toInstant().getEpochSecond()) : value.toString(); + } + + private Set groups(Claims claims) { + Set groups = new LinkedHashSet<>(); + for (String claim : groupsClaims) { + groups.addAll(claims.all(AuthClaims.NS_JWT, claim)); + } + return groups; + } +} diff --git a/server/auth.oidc/src/main/java/org/eclipse/daanse/xmla/server/auth/oidc/package-info.java b/server/auth.oidc/src/main/java/org/eclipse/daanse/xmla/server/auth/oidc/package-info.java new file mode 100644 index 00000000..7982fb2f --- /dev/null +++ b/server/auth.oidc/src/main/java/org/eclipse/daanse/xmla/server/auth/oidc/package-info.java @@ -0,0 +1,25 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * OAuth and OIDC bearer tokens, validated in this process. + *

+ * {@code BearerAuthenticator} checks a token's signature against the keys the + * issuer publishes, and its issuer, audience, type and expiry, before anything + * else happens. The token's own claims become the caller's claims, under this + * mechanism's namespace. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for why that namespace matters + * and how claims become roles. + */ +package org.eclipse.daanse.xmla.server.auth.oidc; diff --git a/server/auth.oidc/src/test/java/org/eclipse/daanse/xmla/server/auth/oidc/BearerAuthenticatorTest.java b/server/auth.oidc/src/test/java/org/eclipse/daanse/xmla/server/auth/oidc/BearerAuthenticatorTest.java new file mode 100644 index 00000000..3a534f4e --- /dev/null +++ b/server/auth.oidc/src/test/java/org/eclipse/daanse/xmla/server/auth/oidc/BearerAuthenticatorTest.java @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.oidc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.XmlaRequest; +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.XmlaAuthenticator; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; + +/** + * Token validation against a real key set. + *

+ * The audience case is the one that mattered: the shorter claims verifier + * compares the whole {@code aud} list, so a token naming this endpoint + * among others - which is what Keycloak issues by default - was + * refused. + */ +class BearerAuthenticatorTest { + + private static final String ISSUER = "https://issuer.example.org"; + private static final String AUDIENCE = "daanse-xmla"; + + private static RSAKey key; + private static HttpServer jwks; + private static String jwksUri; + + @BeforeAll + static void publishKeys() throws Exception { + key = new RSAKeyGenerator(2048).keyID("test").generate(); + byte[] body = new JWKSet(key.toPublicJWK()).toString().getBytes(StandardCharsets.UTF_8); + + jwks = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + jwks.createContext("/jwks", exchange -> { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var out = exchange.getResponseBody()) { + out.write(body); + } + }); + jwks.start(); + jwksUri = "http://127.0.0.1:" + jwks.getAddress().getPort() + "/jwks"; + } + + @AfterAll + static void stop() { + if (jwks != null) { + jwks.stop(0); + } + } + + private static BearerAuthenticator.Config config(String issuer, String[] audiences, boolean allowInsecure) { + return new BearerAuthenticator.Config() { + + @Override + public Class annotationType() { + return BearerAuthenticator.Config.class; + } + + @Override + public String issuer() { + return issuer; + } + + @Override + public String jwksUri() { + return jwksUri; + } + + @Override + public String[] audiences() { + return audiences; + } + + @Override + public String[] algorithms() { + return new String[] { "RS256" }; + } + + @Override + public String[] tokenTypes() { + return new String[] { "at+jwt", "JWT" }; + } + + @Override + public String principalClaim() { + return "sub"; + } + + @Override + public String[] groupsClaims() { + return new String[] { "groups" }; + } + + @Override + public int clockSkewSeconds() { + return 60; + } + + @Override + public int jwksTimeoutMillis() { + return 2000; + } + + @Override + public boolean allowInsecureJwks() { + return allowInsecure; + } + }; + } + + private static BearerAuthenticator authenticator() throws Exception { + BearerAuthenticator authenticator = new BearerAuthenticator(); + authenticator.roles = (principal, claims, external) -> Set.copyOf(external); + // The key set is served over plaintext loopback here, which the component + // otherwise refuses for good reason. + authenticator.activate(config(ISSUER, new String[] { AUDIENCE }, true)); + return authenticator; + } + + private static String token(JWTClaimsSet claims, JOSEObjectType type) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).type(type).build(), claims); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } + + private static JWTClaimsSet.Builder valid() { + Instant now = Instant.now(); + return new JWTClaimsSet.Builder().issuer(ISSUER).subject("alice").issueTime(Date.from(now)) + .expirationTime(Date.from(now.plusSeconds(300))); + } + + private static XmlaRequest bearing(String token) { + Map> headers = token == null ? Map.of() + : Map.of("Authorization", List.of("Bearer " + token)); + return new XmlaRequest(null, null, headers, null, null); + } + + @Test + void aTokenNamingThisEndpointAmongOthersIsAccepted() throws Exception { + // Keycloak's default shape. The two-argument claims verifier compared the whole + // list and refused every one of these. + String token = token( + valid().audience(List.of(AUDIENCE, "account")).claim("groups", List.of("bi-admin")).build(), + JOSEObjectType.JWT); + + XmlaAuthenticator.Result result = authenticator().authenticate(bearing(token)); + + assertThat(result).isInstanceOf(XmlaAuthenticator.Result.Authenticated.class); + var identity = ((XmlaAuthenticator.Result.Authenticated) result).identity(); + assertThat(identity.name()).isEqualTo("alice"); + assertThat(identity.roles()).containsExactly("bi-admin"); + } + + @Test + void aTokenForAnotherServiceIsNot() throws Exception { + String token = token(valid().audience(List.of("some-other-service")).build(), JOSEObjectType.JWT); + + assertThat(authenticator().authenticate(bearing(token))).isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void aTokenFromAnotherIssuerIsNot() throws Exception { + String token = token(valid().issuer("https://elsewhere.example.org").audience(AUDIENCE).build(), + JOSEObjectType.JWT); + + assertThat(authenticator().authenticate(bearing(token))).isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void anExpiredTokenIsNot() throws Exception { + Instant past = Instant.now().minusSeconds(3600); + String token = token(new JWTClaimsSet.Builder().issuer(ISSUER).subject("alice").audience(AUDIENCE) + .issueTime(Date.from(past)).expirationTime(Date.from(past.plusSeconds(60))).build(), + JOSEObjectType.JWT); + + assertThat(authenticator().authenticate(bearing(token))).isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void aTokenOfTheWrongTypeIsNot() throws Exception { + // An ID token standing in for an access token. + String token = token(valid().audience(AUDIENCE).build(), new JOSEObjectType("id_token+jwt")); + + assertThat(authenticator().authenticate(bearing(token))).isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void aTokenSignedByAnotherKeyIsNot() throws Exception { + RSAKey other = new RSAKeyGenerator(2048).keyID("test").generate(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test").type(JOSEObjectType.JWT).build(), + valid().audience(AUDIENCE).build()); + jwt.sign(new RSASSASigner(other)); + + assertThat(authenticator().authenticate(bearing(jwt.serialize()))) + .isInstanceOf(XmlaAuthenticator.Result.Refused.class); + } + + @Test + void theTokensClaimsCannotReachAnotherMechanismsNamespace() throws Exception { + // A dn claim here must not be readable where a directory bind's result is read, + // or the caller would choose which entry a group lookup asks about. + String token = token(valid().audience(AUDIENCE).claim("dn", "cn=somebody-else").build(), JOSEObjectType.JWT); + + XmlaAuthenticator.Result result = authenticator().authenticate(bearing(token)); + + var claims = ((XmlaAuthenticator.Result.Authenticated) result).identity().claims(); + assertThat(claims.first(AuthClaims.LDAP_DN)).isNull(); + assertThat(claims.first(AuthClaims.NS_JWT, "dn")).isEqualTo("cn=somebody-else"); + } + + @Test + void timesAreReportedAsNumbersRatherThanLocalisedText() throws Exception { + String token = token(valid().audience(AUDIENCE).build(), JOSEObjectType.JWT); + + var claims = ((XmlaAuthenticator.Result.Authenticated) authenticator().authenticate(bearing(token))).identity() + .claims(); + + assertThat(claims.first(AuthClaims.NS_JWT, "exp")).containsOnlyDigits(); + } + + @Test + void aRequestWithNoTokenIsNotThisMechanismsBusiness() throws Exception { + assertThat(authenticator().authenticate(bearing(null))).isInstanceOf(XmlaAuthenticator.Result.NotMine.class); + } + + @Test + void aPlaintextKeySetIsRefusedUnlessSaidOtherwise() { + BearerAuthenticator authenticator = new BearerAuthenticator(); + + // Anyone on the path could otherwise substitute the keys and mint tokens this + // server accepts. + assertThatThrownBy(() -> authenticator.activate(config(ISSUER, new String[] { AUDIENCE }, false))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void anIssuerlessConfigurationIsRefused() { + assertThatThrownBy(() -> new BearerAuthenticator().activate(config("", new String[] { AUDIENCE }, true))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void anUnverifiableSignatureAlgorithmIsRefused() throws IOException { + BearerAuthenticator.Config unsigned = new BearerAuthenticator.Config() { + + @Override + public Class annotationType() { + return BearerAuthenticator.Config.class; + } + + @Override + public String issuer() { + return ISSUER; + } + + @Override + public String jwksUri() { + return jwksUri; + } + + @Override + public String[] audiences() { + return new String[] { AUDIENCE }; + } + + @Override + public String[] algorithms() { + return new String[] { "none" }; + } + + @Override + public String[] tokenTypes() { + return new String[] { "JWT" }; + } + + @Override + public String principalClaim() { + return "sub"; + } + + @Override + public String[] groupsClaims() { + return new String[] { "groups" }; + } + + @Override + public int clockSkewSeconds() { + return 60; + } + + @Override + public int jwksTimeoutMillis() { + return 2000; + } + + @Override + public boolean allowInsecureJwks() { + return true; + } + }; + + assertThatThrownBy(() -> new BearerAuthenticator().activate(unsigned)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/server/auth.roles/pom.xml b/server/auth.roles/pom.xml new file mode 100644 index 00000000..14baa224 --- /dev/null +++ b/server/auth.roles/pom.xml @@ -0,0 +1,59 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.roles + Daanse XMLA Authentication: Role Resolution and Mapping + Collects the roles every registered provider grants, together with what a + mechanism read itself, and translates that one set into the roles a catalog defines. + Every authentication mechanism takes a single reference on this rather than assembling + the whiteboard for itself. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/ConfiguredRoleMapping.java b/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/ConfiguredRoleMapping.java new file mode 100644 index 00000000..e5f9160d --- /dev/null +++ b/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/ConfiguredRoleMapping.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.roles; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.auth.RoleMapping; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The deployment's own table from identity-provider names to catalog roles. + *

+ * The two vocabularies almost never match: a token says {@code bi-admin}, a + * directory says {@code CN=BI Admins,OU=Groups,DC=example,DC=org}, and the + * catalog knows a role called {@code Admin}. Without this the raw external + * names travel on as catalog roles, which means a directory group name silently + * becomes a role name - and a group that happens to share a name with a defined + * role becomes an unintended grant. + */ +@Component(service = RoleMapping.class, configurationPolicy = ConfigurationPolicy.REQUIRE) +@Designate(ocd = ConfiguredRoleMapping.Config.class, factory = true) +public class ConfiguredRoleMapping implements RoleMapping { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConfiguredRoleMapping.class); + + /** What becomes of a name no rule mentions. */ + public enum Unmapped { + + /** + * It grants nothing. A catalog refuses a role it does not define, so letting an + * unknown group through would turn it into a failed request rather than into no + * extra access. + */ + DROP, + + /** It is used as the role name. For a deployment whose names already agree. */ + PASS_THROUGH + } + + private volatile List rules = List.of(); + private volatile Unmapped unmapped = Unmapped.DROP; + private volatile boolean caseSensitive; + + @ObjectClassDefinition + @interface Config { + + /** + * One rule per entry, {@code external=Role}. + *

+ * Split at the last {@code =}, so a distinguished name on the left + * needs no escaping. The left side may join several names with {@code +}, and + * the rule then fires only for a caller holding all of them - which is why a + * mapping is handed the whole set at once. + * + *

+         * bi-admin=Admin
+         * CN=BI Admins,OU=Groups,DC=example,DC=org=Admin
+         * bi-analyst+bi-eu=EuropeAnalyst
+         * 
+ */ + String[] rules() default {}; + + /** What happens to a name no rule mentions. */ + Unmapped unmapped() default Unmapped.DROP; + + /** + * Whether the external names are matched exactly. Directory group names and + * distinguished names are conventionally case-insensitive, so they are not by + * default. + */ + boolean caseSensitiveExternalNames() default false; + } + + /** + * @param required every external name that must be present for {@link #role} to + * be granted + */ + private record Rule(Set required, String role) { + } + + @Activate + void activate(Config config) { + boolean exact = config.caseSensitiveExternalNames(); + List parsed = new ArrayList<>(); + for (String entry : config.rules()) { + Rule rule = parse(entry, exact); + if (rule != null) { + parsed.add(rule); + } + } + this.caseSensitive = exact; + this.unmapped = config.unmapped(); + this.rules = List.copyOf(parsed); + LOGGER.debug("{} role mapping rules, unmapped names {}", parsed.size(), config.unmapped()); + } + + private static Rule parse(String entry, boolean caseSensitive) { + if (entry == null || entry.isBlank()) { + return null; + } + int separator = entry.lastIndexOf('='); + if (separator <= 0 || separator == entry.length() - 1) { + throw new IllegalArgumentException("the role mapping rule '" + entry + "' is not 'external=Role'"); + } + Set required = new LinkedHashSet<>(); + for (String name : entry.substring(0, separator).split("\\+")) { + String trimmed = name.trim(); + if (!trimmed.isEmpty()) { + required.add(normalise(trimmed, caseSensitive)); + } + } + if (required.isEmpty()) { + throw new IllegalArgumentException("the role mapping rule '" + entry + "' names nothing to match"); + } + return new Rule(Set.copyOf(required), entry.substring(separator + 1).trim()); + } + + private static String normalise(String name, boolean caseSensitive) { + return caseSensitive ? name : name.toLowerCase(Locale.ROOT); + } + + @Override + public Set map(Set external) { + if (external == null || external.isEmpty()) { + return Set.of(); + } + boolean exact = caseSensitive; + Set held = new LinkedHashSet<>(); + for (String name : external) { + held.add(normalise(name, exact)); + } + + Set granted = new LinkedHashSet<>(); + Set recognised = new LinkedHashSet<>(); + for (Rule rule : rules) { + if (held.containsAll(rule.required())) { + granted.add(rule.role()); + recognised.addAll(rule.required()); + } + } + if (unmapped == Unmapped.PASS_THROUGH) { + for (String name : external) { + if (!recognised.contains(normalise(name, exact))) { + granted.add(name); + } + } + } + return Set.copyOf(granted); + } +} diff --git a/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/DefaultRoleResolution.java b/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/DefaultRoleResolution.java new file mode 100644 index 00000000..4ed695dd --- /dev/null +++ b/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/DefaultRoleResolution.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.roles; + +import java.security.Principal; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.RoleMapping; +import org.eclipse.daanse.xmla.api.auth.RoleProvider; +import org.eclipse.daanse.xmla.api.auth.RoleResolution; +import org.eclipse.daanse.xmla.api.auth.Roles; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.osgi.service.component.annotations.ReferencePolicyOption; + +/** + * The one place the role sources are put together. + *

+ * Before this existed, each mechanism declared the provider whiteboard and the + * optional mapping for itself, and two of them applied the mapping twice - once + * to the groups they had read and again to the providers' answers. A mapping + * therefore never saw the whole set, and a rule over a combination could not + * fire. Here the union is formed first and mapped once. + */ +@Component(service = RoleResolution.class) +public class DefaultRoleResolution implements RoleResolution { + + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + private final List providers = new CopyOnWriteArrayList<>(); + + @Reference(cardinality = ReferenceCardinality.OPTIONAL, policyOption = ReferencePolicyOption.GREEDY) + private volatile RoleMapping mapping; + + @Override + public Set resolve(Principal principal, Claims claims, Collection external) { + return Roles.resolve(principal, claims, external, providers, mapping); + } +} diff --git a/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/package-info.java b/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/package-info.java new file mode 100644 index 00000000..85440aa2 --- /dev/null +++ b/server/auth.roles/src/main/java/org/eclipse/daanse/xmla/server/auth/roles/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +/** + * How a caller's roles are decided, for every mechanism at once. + *

+ * {@code DefaultRoleResolution} is the service every mechanism references: it + * collects what each registered {@code RoleProvider} grants, adds the names the + * mechanism read itself, and translates the union once. + * {@code ConfiguredRoleMapping} is that translation, driven by a table in the + * deployment's configuration. + */ +package org.eclipse.daanse.xmla.server.auth.roles; diff --git a/server/auth.roles/src/test/java/org/eclipse/daanse/xmla/server/auth/roles/ConfiguredRoleMappingTest.java b/server/auth.roles/src/test/java/org/eclipse/daanse/xmla/server/auth/roles/ConfiguredRoleMappingTest.java new file mode 100644 index 00000000..ddfb4ffd --- /dev/null +++ b/server/auth.roles/src/test/java/org/eclipse/daanse/xmla/server/auth/roles/ConfiguredRoleMappingTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.roles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.annotation.Annotation; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * What the table does with names it knows, names it does not, and rules it + * cannot read. + */ +class ConfiguredRoleMappingTest { + + private static ConfiguredRoleMapping mapping(ConfiguredRoleMapping.Unmapped unmapped, boolean caseSensitive, + String... rules) { + ConfiguredRoleMapping mapping = new ConfiguredRoleMapping(); + mapping.activate(new ConfiguredRoleMapping.Config() { + + @Override + public Class annotationType() { + return ConfiguredRoleMapping.Config.class; + } + + @Override + public String[] rules() { + return rules; + } + + @Override + public ConfiguredRoleMapping.Unmapped unmapped() { + return unmapped; + } + + @Override + public boolean caseSensitiveExternalNames() { + return caseSensitive; + } + }); + return mapping; + } + + private static ConfiguredRoleMapping mapping(String... rules) { + return mapping(ConfiguredRoleMapping.Unmapped.DROP, false, rules); + } + + @Test + void aNameIsTranslatedIntoTheRoleTheCatalogDefines() { + assertThat(mapping("bi-admin=Admin").map(Set.of("bi-admin"))).containsExactly("Admin"); + } + + @Test + void aDistinguishedNameNeedsNoEscaping() { + // Splitting at the first '=' would make the left side "CN". + String dn = "CN=BI Admins,OU=Groups,DC=example,DC=org"; + + assertThat(mapping(dn + "=Admin").map(Set.of(dn))).containsExactly("Admin"); + } + + @Test + void aRuleMayDependOnHoldingSeveralNames() { + ConfiguredRoleMapping mapping = mapping("bi-analyst+bi-eu=EuropeAnalyst"); + + assertThat(mapping.map(Set.of("bi-analyst", "bi-eu"))).containsExactly("EuropeAnalyst"); + assertThat(mapping.map(Set.of("bi-analyst"))).isEmpty(); + } + + @Test + void anUnknownNameGrantsNothing() { + // A catalog refuses a role it does not define, so passing it on would turn an + // unknown group into a failed request rather than into no extra access. + assertThat(mapping("bi-admin=Admin").map(Set.of("something-else"))).isEmpty(); + } + + @Test + void aDeploymentWhoseNamesAlreadyAgreeCanSaySo() { + ConfiguredRoleMapping mapping = mapping(ConfiguredRoleMapping.Unmapped.PASS_THROUGH, false, "bi-admin=Admin"); + + assertThat(mapping.map(Set.of("bi-admin", "Analyst"))).containsExactlyInAnyOrder("Admin", "Analyst"); + } + + @Test + void withNoRulesAtAllPassThroughIsTheIdentityMapping() { + ConfiguredRoleMapping mapping = mapping(ConfiguredRoleMapping.Unmapped.PASS_THROUGH, false); + + assertThat(mapping.map(Set.of("Admin", "Analyst"))).containsExactlyInAnyOrder("Admin", "Analyst"); + } + + @Test + void directoryNamesMatchRegardlessOfCaseUnlessAskedOtherwise() { + assertThat(mapping("CN=BI Admins=Admin").map(Set.of("cn=bi admins"))).containsExactly("Admin"); + assertThat(mapping(ConfiguredRoleMapping.Unmapped.DROP, true, "CN=BI Admins=Admin").map(Set.of("cn=bi admins"))) + .isEmpty(); + } + + @Test + void aRuleThatCannotBeReadStopsTheComponentComingUp() { + // Better than a mapping that silently grants less than the operator wrote. + assertThatThrownBy(() -> mapping("no-separator")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> mapping("=Admin")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> mapping("bi-admin=")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nothingInIsNothingOut() { + assertThat(mapping("bi-admin=Admin").map(Set.of())).isEmpty(); + assertThat(mapping("bi-admin=Admin").map(null)).isEmpty(); + } +} diff --git a/server/auth.store.internal/pom.xml b/server/auth.store.internal/pom.xml new file mode 100644 index 00000000..0c11a71a --- /dev/null +++ b/server/auth.store.internal/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.store.internal + Daanse XMLA Authentication Store: Internal + Users, hashed passwords and roles from the deployment's own configuration, for + a server that has no directory to ask. Verification and role lookup are registered + separately, so either can be combined with another source. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + diff --git a/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/InternalUserStore.java b/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/InternalUserStore.java new file mode 100644 index 00000000..38d85d26 --- /dev/null +++ b/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/InternalUserStore.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.internal; + +import java.security.Principal; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.RoleProvider; +import org.eclipse.daanse.xmla.api.auth.XmlaCredentials; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Users, passwords and roles from the deployment's own configuration. + *

+ * For a server with no directory to ask. Passwords are stored as PBKDF2 hashes, + * never in the clear - see {@link PasswordHash} for the encoded form and for + * how to produce it. + *

+ * It answers both questions, but they stay separable: a deployment that keeps + * its passwords here and its roles in a directory registers the directory's + * provider as well, and the roles are the union. + */ +@Component(service = { XmlaCredentials.class, RoleProvider.class }, configurationPolicy = ConfigurationPolicy.REQUIRE) +@Designate(ocd = InternalUserStore.Config.class) +public class InternalUserStore implements XmlaCredentials, RoleProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(InternalUserStore.class); + + private volatile Map passwords = Map.of(); + private volatile Map> roles = Map.of(); + + /** + * Checked when no user of that name exists, so that answer costs what a real + * one costs. Otherwise an unknown name comes back in microseconds while a known + * one runs the whole key derivation, and anyone who can time a request can read + * off which accounts exist. + */ + private volatile String decoy = PasswordHash.encode("there is no such user".toCharArray()); + + @ObjectClassDefinition + @interface Config { + + /** + * One entry per user, {@code name=}. The encoded form is what + * {@link PasswordHash} produces; an entry in any other shape is refused rather + * than read as a plain password. + */ + String[] credentials() default {}; + + /** One entry per user, {@code name=Role,Role}. A user may have none. */ + String[] roles() default {}; + } + + @Activate + void activate(Config config) { + Map encoded = entries(config.credentials()); + for (Map.Entry entry : encoded.entrySet()) { + if (!PasswordHash.isEncoded(entry.getValue())) { + // Otherwise a typo is discovered at the first login, as an indistinguishable + // "wrong password", possibly months later. + throw new IllegalStateException("the password of '" + entry.getKey() + "' is not in the form " + + "PasswordHash produces; a plain password is refused rather than read"); + } + } + passwords = encoded; + Map> granted = new LinkedHashMap<>(); + for (Map.Entry entry : entries(config.roles()).entrySet()) { + granted.put(entry.getKey(), split(entry.getValue())); + } + roles = Map.copyOf(granted); + LOGGER.debug("internal user store holds {} users", passwords.size()); + } + + @Override + public Optional verify(String userName, char[] password) { + if (userName == null || password == null || password.length == 0) { + return Optional.empty(); + } + String encoded = passwords.get(userName); + // An unknown user and a wrong password answer alike, and take alike as long: + // the decoy is checked so the two cannot be told apart by timing either. + boolean accepted = PasswordHash.matches(password, encoded == null ? decoy : encoded); + if (encoded == null || !accepted) { + return Optional.empty(); + } + Principal principal = new NamedPrincipal(userName); + return Optional.of(new AuthenticatedIdentity(principal, rolesOf(userName), Claims.none())); + } + + @Override + public Set rolesOf(Principal principal, Claims claims) { + return principal == null ? Set.of() : rolesOf(principal.getName()); + } + + private Set rolesOf(String userName) { + return roles.getOrDefault(userName, Set.of()); + } + + /** {@code name=value} entries, split at the first {@code =} only. */ + private static Map entries(String[] configured) { + Map parsed = new LinkedHashMap<>(); + for (String entry : configured) { + if (entry == null) { + continue; + } + int separator = entry.indexOf('='); + if (separator <= 0) { + LOGGER.warn("ignoring the configuration entry '{}': it names no user", entry); + continue; + } + String name = entry.substring(0, separator).trim(); + if (name.isEmpty()) { + // " =hash" would otherwise become a usable account with an invisible name. + throw new IllegalStateException("a configuration entry names a user whose name is blank"); + } + if (parsed.put(name, entry.substring(separator + 1).trim()) != null) { + // Last one wins is a silent way to lose an account. + throw new IllegalStateException("the user '" + name + "' is configured more than once"); + } + } + return Map.copyOf(parsed); + } + + private static Set split(String value) { + Set parts = new LinkedHashSet<>(); + for (String part : List.of(value.split(","))) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + parts.add(trimmed); + } + } + return Set.copyOf(parts); + } + +} diff --git a/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/PasswordHash.java b/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/PasswordHash.java new file mode 100644 index 00000000..468fd417 --- /dev/null +++ b/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/PasswordHash.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.internal; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +/** + * How a password is stored and checked here. + *

+ * PBKDF2 with a per-user salt, from the JDK, so nothing is added to the class + * path for it. The encoded form carries everything needed to check against it + * later, which is what lets the iteration count be raised without invalidating + * what is already configured: + * + *

+ * pbkdf2:sha256:<iterations>:<salt-base64>:<hash-base64>
+ * 
+ *

+ * Comparison is time-constant. A comparison that returns early on the first + * wrong byte tells anyone who can measure it how much of a guess was right. + */ +public final class PasswordHash { + + private static final String ALGORITHM = "PBKDF2WithHmacSHA256"; + private static final String PREFIX = "pbkdf2:sha256:"; + private static final int DEFAULT_ITERATIONS = 210_000; + private static final int SALT_BYTES = 16; + private static final int KEY_BITS = 256; + + private PasswordHash() { + // static access only + } + + /** The encoded form to put in the configuration. */ + public static String encode(char[] password) { + byte[] salt = new byte[SALT_BYTES]; + new SecureRandom().nextBytes(salt); + byte[] hash = derive(password, salt, DEFAULT_ITERATIONS); + Base64.Encoder encoder = Base64.getEncoder(); + return PREFIX + DEFAULT_ITERATIONS + ":" + encoder.encodeToString(salt) + ":" + encoder.encodeToString(hash); + } + + /** + * Whether this is the encoded form at all, without checking any password + * against it. + */ + public static boolean isEncoded(String encoded) { + return encoded != null && encoded.startsWith(PREFIX) + && encoded.substring(PREFIX.length()).split(":").length == 3; + } + + /** + * Whether the password produces the encoded hash. + * + * @return {@code false} for anything unreadable, so a malformed entry denies + * access rather than granting it + */ + public static boolean matches(char[] password, String encoded) { + if (password == null || encoded == null || !encoded.startsWith(PREFIX)) { + return false; + } + String[] parts = encoded.substring(PREFIX.length()).split(":"); + if (parts.length != 3) { + return false; + } + try { + int iterations = Integer.parseInt(parts[0]); + byte[] salt = Base64.getDecoder().decode(parts[1]); + byte[] expected = Base64.getDecoder().decode(parts[2]); + byte[] actual = derive(password, salt, iterations); + return MessageDigest.isEqual(expected, actual); + } catch (IllegalArgumentException unreadable) { + return false; + } + } + + private static byte[] derive(char[] password, byte[] salt, int iterations) { + PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, KEY_BITS); + try { + return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).getEncoded(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new IllegalStateException("the JDK does not provide " + ALGORITHM, e); + } finally { + spec.clearPassword(); + } + } + + /** + * Produces the encoded form for a password, so a deployment can fill its + * configuration without writing code: + * + *

+     * java -cp <this bundle> org.eclipse.daanse.xmla.server.auth.store.internal.PasswordHash secret
+     * 
+ */ + public static void main(String[] args) { + if (args.length != 1) { + System.err.println("usage: PasswordHash "); + return; + } + System.out.println(encode(args[0].toCharArray())); + } +} diff --git a/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/package-info.java b/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/package-info.java new file mode 100644 index 00000000..d6a96886 --- /dev/null +++ b/server/auth.store.internal/src/main/java/org/eclipse/daanse/xmla/server/auth/store/internal/package-info.java @@ -0,0 +1,25 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * Users, passwords and roles from the deployment's own configuration. + *

+ * {@code InternalUserStore} answers both the password question and the role + * question, registered separately so either can be combined with another + * source. {@code PasswordHash} is the stored form - PBKDF2 with a per-user salt + * - and produces the encoded value a configuration carries. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for how the two questions + * compose. + */ +package org.eclipse.daanse.xmla.server.auth.store.internal; diff --git a/server/auth.store.internal/src/test/java/org/eclipse/daanse/xmla/server/auth/store/internal/InternalUserStoreTest.java b/server/auth.store.internal/src/test/java/org/eclipse/daanse/xmla/server/auth/store/internal/InternalUserStoreTest.java new file mode 100644 index 00000000..35bf3067 --- /dev/null +++ b/server/auth.store.internal/src/test/java/org/eclipse/daanse/xmla/server/auth/store/internal/InternalUserStoreTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.annotation.Annotation; + +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.junit.jupiter.api.Test; + +/** What the store must and must not accept. */ +class InternalUserStoreTest { + + private static final String ALICE = PasswordHash.encode("alicepw".toCharArray()); + + private static InternalUserStore storeOf(String[] credentials, String[] roles) { + InternalUserStore store = new InternalUserStore(); + store.activate(new InternalUserStore.Config() { + + @Override + public Class annotationType() { + return InternalUserStore.Config.class; + } + + @Override + public String[] credentials() { + return credentials; + } + + @Override + public String[] roles() { + return roles; + } + }); + return store; + } + + @Test + void theRightPasswordIsAcceptedAndCarriesTheRoles() { + InternalUserStore store = storeOf(new String[] { "alice=" + ALICE }, new String[] { "alice=Admin, Analyst" }); + + var identity = store.verify("alice", "alicepw".toCharArray()); + + assertThat(identity).isPresent(); + assertThat(identity.get().name()).isEqualTo("alice"); + assertThat(identity.get().roles()).containsExactlyInAnyOrder("Admin", "Analyst"); + } + + @Test + void theWrongPasswordIsNot() { + InternalUserStore store = storeOf(new String[] { "alice=" + ALICE }, new String[0]); + assertThat(store.verify("alice", "wrong".toCharArray())).isEmpty(); + } + + @Test + void anUnknownUserIsNot() { + InternalUserStore store = storeOf(new String[] { "alice=" + ALICE }, new String[0]); + assertThat(store.verify("mallory", "alicepw".toCharArray())).isEmpty(); + } + + @Test + void anEmptyPasswordIsNot() { + InternalUserStore store = storeOf(new String[] { "alice=" + ALICE }, new String[0]); + assertThat(store.verify("alice", new char[0])).isEmpty(); + } + + @Test + void aPlainPasswordInTheConfigurationStopsTheComponentComingUp() { + // Otherwise a deployment could put the password itself there, and the mistake + // would surface at the first login as an indistinguishable "wrong password". + assertThatThrownBy(() -> storeOf(new String[] { "alice=alicepw" }, new String[0])) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void aUserConfiguredTwiceStopsTheComponentComingUp() { + // Last one wins is a silent way to lose an account. + assertThatThrownBy(() -> storeOf(new String[] { "alice=" + ALICE, "alice=" + ALICE }, new String[0])) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void aUserWhoseNameIsOnlyWhitespaceIsRefused() { + assertThatThrownBy(() -> storeOf(new String[] { " =" + ALICE }, new String[0])) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void anUnknownUserCostsWhatAKnownOneCosts() { + // The short-circuit this replaces let anyone with a stopwatch read off which + // accounts exist. + InternalUserStore store = storeOf(new String[] { "alice=" + ALICE }, new String[0]); + + long known = timeOf(() -> store.verify("alice", "wrong".toCharArray())); + long unknown = timeOf(() -> store.verify("mallory", "wrong".toCharArray())); + + assertThat(unknown).isGreaterThan(known / 4); + } + + private static long timeOf(Runnable attempt) { + long started = System.nanoTime(); + attempt.run(); + return System.nanoTime() - started; + } + + @Test + void anEntryNamingNoUserIsIgnored() { + InternalUserStore store = storeOf(new String[] { "nonsense", "alice=" + ALICE }, new String[0]); + assertThat(store.verify("alice", "alicepw".toCharArray())).isPresent(); + } + + @Test + void rolesAreAlsoAnsweredForACallerAnotherMechanismAuthenticated() { + InternalUserStore store = storeOf(new String[0], new String[] { "alice=Admin" }); + + assertThat(store.rolesOf(() -> "alice", Claims.none())).containsExactly("Admin"); + assertThat(store.rolesOf(() -> "bob", Claims.none())).isEmpty(); + } + + @Test + void everyHashIsSaltedSoTwoUsersWithOnePasswordDifferOnDisk() { + assertThat(PasswordHash.encode("same".toCharArray())).isNotEqualTo(PasswordHash.encode("same".toCharArray())); + } + + @Test + void anUnreadableHashDeniesRatherThanGrants() { + assertThat(PasswordHash.matches("x".toCharArray(), "pbkdf2:sha256:notanumber:a:b")).isFalse(); + assertThat(PasswordHash.matches("x".toCharArray(), "")).isFalse(); + assertThat(PasswordHash.matches("x".toCharArray(), null)).isFalse(); + } +} diff --git a/server/auth.store.ldap/pom.xml b/server/auth.store.ldap/pom.xml new file mode 100644 index 00000000..f7186ac6 --- /dev/null +++ b/server/auth.store.ldap/pom.xml @@ -0,0 +1,67 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.xmla.server + ${revision} + + org.eclipse.daanse.xmla.server.auth.store.ldap + Daanse XMLA Authentication Store: LDAP / Active Directory + LDAP as two separate services: password verification by simple bind, and role + lookup by group membership. Either can be used on its own, so a deployment may verify + Basic credentials against the directory while taking roles from a token, or the other way + round. Uses the JDK's own directory client, so it adds no dependency. + + + org.eclipse.daanse + org.eclipse.daanse.xmla.api + ${revision} + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.assertj + assertj-core + 3.25.3 + test + + + org.testcontainers + testcontainers + 1.21.3 + test + + + org.testcontainers + junit-jupiter + 1.21.3 + test + + + diff --git a/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/Directory.java b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/Directory.java new file mode 100644 index 00000000..074c4fff --- /dev/null +++ b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/Directory.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; + +import java.io.IOException; +import java.util.Hashtable; + +import javax.naming.Context; +import javax.naming.NamingException; +import javax.naming.directory.DirContext; +import javax.naming.ldap.InitialLdapContext; +import javax.naming.ldap.LdapContext; +import javax.naming.ldap.StartTlsRequest; +import javax.naming.ldap.StartTlsResponse; + +/** + * How this bundle opens a connection to the directory. + *

+ * Two kinds of bind happen here and they must not be confused: the service + * bind, which searches, and the user bind, which is the password check itself. + * A search performed as the user would answer differently depending on who + * asks; a password checked with the service account would not be checked at + * all. + */ +final class Directory { + + private Directory() { + // static access only + } + + /** How the directory is reached, decided once when the component comes up. */ + record Settings(String url, TlsMode tls, int connectTimeoutMillis, int readTimeoutMillis, String referral, + String bindDn, String bindPassword) { + } + + /** + * An open context, together with whatever has to be unwound before it closes. + */ + record Connection(DirContext context, StartTlsResponse tls) implements AutoCloseable { + + @Override + public void close() { + if (tls != null) { + try { + tls.close(); + } catch (IOException ignored) { + // the connection is being dropped anyway + } + } + if (context != null) { + try { + context.close(); + } catch (NamingException ignored) { + // a context that will not close is already unusable + } + } + } + } + + /** + * A context bound as the service account, or anonymously when none is + * configured. Used for searching only. + */ + static Connection asService(Settings settings) throws NamingException { + boolean anonymous = settings.bindDn() == null || settings.bindDn().isBlank(); + // Pooling is safe here and only here: the JNDI pool keys on the bound principal + // and its credentials, so pooling a per-user bind would keep every caller's + // password in memory for the lifetime of the pool. + return open(settings, anonymous ? null : settings.bindDn(), + anonymous ? null : settings.bindPassword().toCharArray(), !anonymous); + } + + /** + * A context bound as the user. Opening it is the password check: the + * directory refuses the bind when the password is wrong. + * + * @throws NamingException if the password is empty - LDAP would read that as a + * request for an anonymous bind and report success, + * which would be an authentication bypass + */ + static Connection asUser(Settings settings, String userDn, char[] password) throws NamingException { + if (password == null || password.length == 0) { + throw new javax.naming.AuthenticationException("an empty password is not a password"); + } + return open(settings, userDn, password, false); + } + + private static Connection open(Settings settings, String principal, char[] credentials, boolean pooled) + throws NamingException { + Hashtable environment = base(settings); + if (settings.tls() == TlsMode.STARTTLS) { + // The upgrade has to happen before any credential travels, so the context is + // opened unauthenticated and the bind follows on the protected connection. + // Pooling is incompatible with that and is simply not used. + return startTls(environment, principal, credentials); + } + if (pooled) { + environment.put("com.sun.jndi.ldap.connect.pool", "true"); + } + bind(environment, principal, credentials); + return new Connection(new InitialLdapContext(environment, null), null); + } + + private static Connection startTls(Hashtable environment, String principal, char[] credentials) + throws NamingException { + LdapContext context = new InitialLdapContext(environment, null); + StartTlsResponse tls = null; + try { + tls = (StartTlsResponse) context.extendedOperation(new StartTlsRequest()); + tls.negotiate(); + if (principal == null) { + context.addToEnvironment(Context.SECURITY_AUTHENTICATION, "none"); + } else { + context.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple"); + context.addToEnvironment(Context.SECURITY_PRINCIPAL, principal); + context.addToEnvironment(Context.SECURITY_CREDENTIALS, new String(credentials)); + } + context.reconnect(null); + return new Connection(context, tls); + } catch (IOException | NamingException | RuntimeException failed) { + new Connection(context, tls).close(); + if (failed instanceof NamingException naming) { + throw naming; + } + if (failed instanceof RuntimeException runtime) { + throw runtime; + } + throw new javax.naming.CommunicationException("StartTLS was refused: " + failed.getMessage()); + } + } + + private static void bind(Hashtable environment, String principal, char[] credentials) { + if (principal == null) { + environment.put(Context.SECURITY_AUTHENTICATION, "none"); + return; + } + environment.put(Context.SECURITY_AUTHENTICATION, "simple"); + environment.put(Context.SECURITY_PRINCIPAL, principal); + environment.put(Context.SECURITY_CREDENTIALS, new String(credentials)); + } + + private static Hashtable base(Settings settings) { + Hashtable environment = new Hashtable<>(); + environment.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); + environment.put(Context.PROVIDER_URL, settings.url()); + // Without these a directory that accepts the connection and then says nothing + // holds the request thread for good, which an unauthenticated caller can + // trigger. + environment.put("com.sun.jndi.ldap.connect.timeout", Integer.toString(settings.connectTimeoutMillis())); + environment.put("com.sun.jndi.ldap.read.timeout", Integer.toString(settings.readTimeoutMillis())); + // A subtree search against Active Directory routinely returns referrals, and + // JNDI's default is to throw at the end of the enumeration rather than say so. + environment.put(Context.REFERRAL, settings.referral()); + if (settings.tls() == TlsMode.LDAPS) { + environment.put(Context.SECURITY_PROTOCOL, "ssl"); + } + return environment; + } +} diff --git a/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapCredentials.java b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapCredentials.java new file mode 100644 index 00000000..6cfe01ca --- /dev/null +++ b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapCredentials.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; + +import java.security.Principal; +import java.util.Optional; + +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; + +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.NamedPrincipal; +import org.eclipse.daanse.xmla.api.auth.XmlaCredentials; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Verifies a password against LDAP or Active Directory, by binding as the user. + *

+ * Binding is the check: the directory refuses a wrong password, and no + * password ever leaves this bundle. The user's entry is found either from a + * pattern, when the directory tree says where users live, or by searching as a + * service account, when it does not. + *

+ * Registered on its own, separate from {@link LdapRoleProvider}, so a + * deployment may verify credentials here and read roles from a token, or the + * other way round. + */ +@Component(service = XmlaCredentials.class, configurationPolicy = ConfigurationPolicy.REQUIRE) +@Designate(ocd = LdapCredentials.Config.class) +public class LdapCredentials implements XmlaCredentials { + + private static final Logger LOGGER = LoggerFactory.getLogger(LdapCredentials.class); + + private volatile Config config; + private volatile Directory.Settings directory; + + @ObjectClassDefinition + @interface Config { + + /** The directory, e.g. {@code ldaps://dc.example.org:636}. */ + String url(); + + /** + * Where the user's entry is, with {@code {0}} for the name as the client sent + * it, e.g. {@code uid={0},ou=people,dc=example,dc=org}. Empty searches instead. + */ + String userDnPattern() default ""; + + /** Where to search for the user's entry when there is no pattern. */ + String userSearchBase() default ""; + + /** The filter to search with, {@code {0}} being the name. */ + String userSearchFilter() default "(uid={0})"; + + /** The account that searches. Empty searches anonymously. */ + String serviceBindDn() default ""; + + String serviceBindPassword() default ""; + + /** How the connection to the directory is protected. */ + TlsMode transportSecurity() default TlsMode.LDAPS; + + /** + * Confirms that {@link #transportSecurity()} of {@code NONE} is meant. Without + * it the component does not come up, because a simple bind on an unprotected + * connection sends the user's password in the clear. + */ + boolean allowUnencrypted() default false; + + int connectTimeoutMillis() default 5000; + + int readTimeoutMillis() default 10000; + + /** + * What to do with a referral: {@code follow}, {@code ignore} or {@code throw}. + */ + String referral() default "follow"; + } + + @Activate + void activate(Config config) { + this.directory = settingsOf(config); + this.config = config; + } + + /** + * @throws IllegalStateException rather than letting a missing value reach the + * directory layer, where a null URL becomes a + * {@code NullPointerException} on a request + * thread that no handler here would catch + */ + static Directory.Settings settingsOf(Config config) { + if (config.url() == null || config.url().isBlank()) { + throw new IllegalStateException("url names the directory to ask and is required"); + } + if (config.transportSecurity() == TlsMode.NONE && !config.allowUnencrypted()) { + throw new IllegalStateException("every password would travel in the clear; set allowUnencrypted to " + + "confirm that is intended, or choose LDAPS or STARTTLS"); + } + if (config.userDnPattern().isBlank() && config.userSearchBase().isBlank()) { + throw new IllegalStateException("either userDnPattern or userSearchBase is needed to find a user"); + } + return new Directory.Settings(config.url(), config.transportSecurity(), config.connectTimeoutMillis(), + config.readTimeoutMillis(), config.referral(), config.serviceBindDn(), config.serviceBindPassword()); + } + + @Override + public Optional verify(String userName, char[] password) { + if (userName == null || userName.isBlank() || password == null || password.length == 0) { + return Optional.empty(); + } + String userDn; + try { + userDn = dnOf(userName); + } catch (NamingException e) { + LOGGER.warn("could not look up {} in the directory", userName, e); + return Optional.empty(); + } + if (userDn == null) { + return Optional.empty(); + } + + try (Directory.Connection asUser = Directory.asUser(directory, userDn, password)) { + Principal principal = new NamedPrincipal(userName); + Claims claims = Claims.in(AuthClaims.NS_LDAP).put(AuthClaims.DN, userDn).build(); + return Optional.of(AuthenticatedIdentity.of(principal, claims)); + } catch (NamingException refused) { + LOGGER.debug("the directory refused the bind for {}", userDn, refused); + return Optional.empty(); + } + } + + /** The user's distinguished name, from the pattern or by searching. */ + private String dnOf(String userName) throws NamingException { + Config current = config; + if (!current.userDnPattern().isBlank()) { + return current.userDnPattern().replace("{0}", LdapNames.escapeDn(userName)); + } + try (Directory.Connection asService = Directory.asService(directory)) { + SearchControls controls = new SearchControls(); + controls.setSearchScope(SearchControls.SUBTREE_SCOPE); + controls.setReturningAttributes(new String[0]); + String filter = current.userSearchFilter().replace("{0}", LdapNames.escapeFilter(userName)); + NamingEnumeration found = asService.context().search(current.userSearchBase(), filter, + controls); + try { + return found.hasMore() ? found.next().getNameInNamespace() : null; + } finally { + close(found); + } + } + } + + /** + * A search left open holds its slot in the connection pool, which is exactly + * the case the pool makes worse rather than better. + */ + private static void close(NamingEnumeration enumeration) { + try { + enumeration.close(); + } catch (NamingException ignored) { + // the search is over either way + } + } +} diff --git a/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapNames.java b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapNames.java new file mode 100644 index 00000000..f658f285 --- /dev/null +++ b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapNames.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; + +import javax.naming.ldap.Rdn; + +/** + * Escaping for the two places a caller-supplied name reaches the directory. + *

+ * Both are template substitutions, so an unescaped name does not merely fail - + * it changes what is being asked. A name of the shape {@code x)(|(cn=*} turns a + * membership filter into one that matches every group in the directory, and + * every one of those names then travels on as a role. The name can come from a + * JWT claim or a proxy header, which an attacker may be able to choose. + */ +final class LdapNames { + + private LdapNames() { + // static access only + } + + /** A value inside a search filter, per RFC 4515 §3. */ + static String escapeFilter(String value) { + if (value == null) { + return ""; + } + StringBuilder escaped = new StringBuilder(value.length() + 8); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '\\' -> escaped.append("\\5c"); + case '*' -> escaped.append("\\2a"); + case '(' -> escaped.append("\\28"); + case ')' -> escaped.append("\\29"); + case '\0' -> escaped.append("\\00"); + default -> escaped.append(character); + } + } + return escaped.toString(); + } + + /** A value inside a distinguished name, per RFC 4514. */ + static String escapeDn(String value) { + return value == null ? "" : Rdn.escapeValue(value); + } +} diff --git a/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapRoleProvider.java b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapRoleProvider.java new file mode 100644 index 00000000..cef7ab0c --- /dev/null +++ b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapRoleProvider.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; + +import java.security.Principal; +import java.util.LinkedHashSet; +import java.util.Set; + +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; + +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; + +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.eclipse.daanse.xmla.api.auth.RoleProvider; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reads a caller's groups from LDAP or Active Directory. + *

+ * Registered on its own, so it answers for every mechanism: a caller who + * arrived with a Kerberos ticket, a bearer token or Basic credentials all get + * their groups from the same directory. The name it searches by is the + * principal's, and when the mechanism already knows the entry's distinguished + * name it is used instead - which is what {@link LdapCredentials} passes along. + *

+ * The group names are the directory's own. Translating them into the roles a + * catalog defines is a {@code RoleMapping}'s business, not this one's. + */ +@Component(service = RoleProvider.class, configurationPolicy = ConfigurationPolicy.REQUIRE) +@Designate(ocd = LdapRoleProvider.Config.class) +public class LdapRoleProvider implements RoleProvider { + + /** + * The claim a directory bind reports the caller's entry under. + * + * @deprecated use {@link AuthClaims#LDAP_DN}, which is namespaced so that a + * token claim of the same name cannot steer this lookup + */ + @Deprecated + public static final String CLAIM_DN = AuthClaims.LDAP_DN; + + private static final Logger LOGGER = LoggerFactory.getLogger(LdapRoleProvider.class); + + private volatile Config config; + private volatile Directory.Settings directory; + + @ObjectClassDefinition + @interface Config { + + /** The directory, e.g. {@code ldaps://dc.example.org:636}. */ + String url(); + + /** Where the groups are, e.g. {@code ou=groups,dc=example,dc=org}. */ + String groupSearchBase(); + + /** + * The filter that finds a caller's groups. {@code {0}} is the entry's + * distinguished name, {@code {1}} the plain name. + */ + String groupSearchFilter() default "(member={0})"; + + /** The attribute holding the group's name. */ + String groupNameAttribute() default "cn"; + + /** + * The attribute on the user's own entry that already lists their groups, as + * Active Directory does. Empty searches instead, which every directory + * supports. + */ + String memberOfAttribute() default ""; + + /** + * Where to look up the entry of a caller who arrived without one - anybody + * authenticated by a token, a ticket or a proxy header. Empty means such a + * caller gets no groups from here. + */ + String userSearchBase() default ""; + + /** The filter that finds that entry, {@code {0}} being the caller's name. */ + String userSearchFilter() default "(uid={0})"; + + /** The account that searches. Empty searches anonymously. */ + String serviceBindDn() default ""; + + String serviceBindPassword() default ""; + + /** How the connection to the directory is protected. */ + TlsMode transportSecurity() default TlsMode.LDAPS; + + /** Confirms that {@link #transportSecurity()} of {@code NONE} is meant. */ + boolean allowUnencrypted() default false; + + int connectTimeoutMillis() default 5000; + + int readTimeoutMillis() default 10000; + + /** + * What to do with a referral: {@code follow}, {@code ignore} or {@code throw}. + */ + String referral() default "follow"; + } + + @Activate + void activate(Config config) { + if (config.url() == null || config.url().isBlank()) { + throw new IllegalStateException("url names the directory to ask and is required"); + } + if (config.groupSearchBase() == null || config.groupSearchBase().isBlank()) { + throw new IllegalStateException("groupSearchBase names where the groups live and is required"); + } + if (config.transportSecurity() == TlsMode.NONE && !config.allowUnencrypted()) { + throw new IllegalStateException("the service bind would travel in the clear; set allowUnencrypted to " + + "confirm that is intended, or choose LDAPS or STARTTLS"); + } + this.directory = new Directory.Settings(config.url(), config.transportSecurity(), config.connectTimeoutMillis(), + config.readTimeoutMillis(), config.referral(), config.serviceBindDn(), config.serviceBindPassword()); + this.config = config; + } + + @Override + public Set rolesOf(Principal principal, Claims claims) { + if (principal == null) { + return Set.of(); + } + Config current = config; + // Only the namespace a directory bind writes. A claim of the same name from a + // token would otherwise decide which entry's groups this caller receives. + String dn = claims == null ? null : claims.first(AuthClaims.LDAP_DN); + try (Directory.Connection asService = Directory.asService(directory)) { + if (dn == null) { + dn = lookUp(asService, current, principal.getName()); + } + if (dn == null) { + LOGGER.debug("no directory entry for {}; configure userSearchBase to look one up", principal.getName()); + return Set.of(); + } + if (!current.memberOfAttribute().isBlank()) { + return listed(asService, current, dn); + } + return searched(asService, current, dn, principal.getName()); + } catch (NamingException e) { + // Not knowing is not refusing: the access policy decides what an empty set + // means, and another provider may still answer. + LOGGER.warn("could not read the groups of {} from the directory", principal.getName(), e); + return Set.of(); + } + } + + /** + * The entry of a caller whose mechanism did not establish one. + *

+ * Without this the plain name used to be substituted into a filter written for + * a distinguished name, which matches nothing - so a token- or ticket- + * authenticated caller silently held no groups at all. + */ + private static String lookUp(Directory.Connection asService, Config config, String name) throws NamingException { + if (config.userSearchBase().isBlank()) { + return null; + } + SearchControls controls = new SearchControls(); + controls.setSearchScope(SearchControls.SUBTREE_SCOPE); + controls.setReturningAttributes(new String[0]); + String filter = config.userSearchFilter().replace("{0}", LdapNames.escapeFilter(name)); + NamingEnumeration found = asService.context().search(config.userSearchBase(), filter, controls); + try { + return found.hasMore() ? found.next().getNameInNamespace() : null; + } finally { + close(found); + } + } + + /** The groups the user's own entry lists, as Active Directory keeps them. */ + private static Set listed(Directory.Connection asService, Config config, String dn) throws NamingException { + Set groups = new LinkedHashSet<>(); + Attribute memberOf = asService.context().getAttributes(dn, new String[] { config.memberOfAttribute() }) + .get(config.memberOfAttribute()); + if (memberOf == null) { + return groups; + } + NamingEnumeration values = memberOf.getAll(); + try { + while (values.hasMore()) { + Object value = values.next(); + if (value != null) { + groups.add(value.toString()); + } + } + } finally { + close(values); + } + return groups; + } + + /** The groups that name the caller as a member. */ + private static Set searched(Directory.Connection asService, Config config, String dn, String name) + throws NamingException { + SearchControls controls = new SearchControls(); + controls.setSearchScope(SearchControls.SUBTREE_SCOPE); + controls.setReturningAttributes(new String[] { config.groupNameAttribute() }); + + // Escaped, because both values can carry a name a caller chose: the plain one + // comes from a token claim or a proxy header. Unescaped, a filter-breaking name + // makes this match every group in the directory. + String filter = config.groupSearchFilter().replace("{0}", LdapNames.escapeFilter(dn)).replace("{1}", + LdapNames.escapeFilter(name)); + Set groups = new LinkedHashSet<>(); + NamingEnumeration found = asService.context().search(config.groupSearchBase(), filter, controls); + try { + while (found.hasMore()) { + Attribute named = found.next().getAttributes().get(config.groupNameAttribute()); + if (named != null && named.get() != null) { + groups.add(named.get().toString()); + } + } + } finally { + close(found); + } + return groups; + } + + private static void close(NamingEnumeration enumeration) { + try { + enumeration.close(); + } catch (NamingException ignored) { + // the search is over either way + } + } +} diff --git a/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/TlsMode.java b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/TlsMode.java new file mode 100644 index 00000000..3088cdff --- /dev/null +++ b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/TlsMode.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; + +/** How the connection to the directory is protected. */ +public enum TlsMode { + + /** TLS from the first byte, on the {@code ldaps://} port. */ + LDAPS, + + /** + * Plain connection upgraded by the StartTLS extended operation, which is the + * usual arrangement on port 389 in an Active Directory domain. + */ + STARTTLS, + + /** + * No protection. A simple bind then sends the user's password in the clear, so + * it has to be confirmed separately. + */ + NONE +} diff --git a/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/package-info.java b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/package-info.java new file mode 100644 index 00000000..219fc6ac --- /dev/null +++ b/server/auth.store.ldap/src/main/java/org/eclipse/daanse/xmla/server/auth/store/ldap/package-info.java @@ -0,0 +1,27 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +/** + * Passwords and groups from LDAP or Active Directory. + *

+ * {@code LdapCredentials} checks a password by binding as the user, which is + * the check itself; {@code LdapRoleProvider} reads that caller's groups. They + * are separate services, so a deployment may use either alone. + * {@code Directory} opens the connection, {@code TlsMode} decides how it is + * protected, and {@code LdapNames} escapes every caller-supplied value that + * reaches a filter or a distinguished name. + *

+ * See {@link org.eclipse.daanse.xmla.api.auth} for how the groups become + * catalog roles. + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; diff --git a/server/auth.store.ldap/src/test/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapStoreTest.java b/server/auth.store.ldap/src/test/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapStoreTest.java new file mode 100644 index 00000000..caaf2a82 --- /dev/null +++ b/server/auth.store.ldap/src/test/java/org/eclipse/daanse/xmla/server/auth/store/ldap/LdapStoreTest.java @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.xmla.server.auth.store.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.annotation.Annotation; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.daanse.xmla.api.auth.AuthenticatedIdentity; +import org.eclipse.daanse.xmla.api.auth.AuthClaims; +import org.eclipse.daanse.xmla.api.auth.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +/** + * The directory bundle against a real directory. + *

+ * A password check that is a bind, and a group search that is a search, are + * exactly the two things a mock cannot tell the truth about: LDAP accepts an + * empty password as an anonymous bind and would report it as success, and the + * filter syntax is only wrong against a server that parses it. + */ +@Testcontainers(disabledWithoutDocker = true) +class LdapStoreTest { + + private static final String ROOT = "dc=example,dc=org"; + private static final String USERS = "ou=users," + ROOT; + + /** The entries the tests need, added once the directory is up. */ + private static final String LDIF = """ + dn: ou=users,dc=example,dc=org + objectClass: organizationalUnit + ou: users + + dn: cn=alice,ou=users,dc=example,dc=org + objectClass: inetOrgPerson + cn: alice + sn: Example + userPassword: alicepw + + dn: cn=bob,ou=users,dc=example,dc=org + objectClass: inetOrgPerson + cn: bob + sn: Example + userPassword: bobpw + + dn: cn=analysts,ou=users,dc=example,dc=org + objectClass: groupOfNames + cn: analysts + member: cn=alice,ou=users,dc=example,dc=org + """; + + @Container + private final GenericContainer directory = new GenericContainer<>(DockerImageName.parse("osixia/openldap:1.5.0")) + .withEnv("LDAP_DOMAIN", "example.org").withEnv("LDAP_ADMIN_PASSWORD", "adminpw") + .withCopyToContainer(Transferable.of(LDIF), "/tmp/daanse.ldif").withExposedPorts(389) + .waitingFor(Wait.forListeningPort()); + + private LdapCredentials credentials; + private LdapRoleProvider roles; + + /** + * Adds the entries, ignoring the complaint on the second call - the container + * outlives a single test method and the entries are then already there. + */ + private void seed() throws Exception { + directory.execInContainer("ldapadd", "-x", "-H", "ldap://localhost", "-D", "cn=admin," + ROOT, "-w", "adminpw", + "-f", "/tmp/daanse.ldif"); + } + + private String url() { + return "ldap://" + directory.getHost() + ":" + directory.getMappedPort(389); + } + + @BeforeEach + void wire() throws Exception { + seed(); + credentials = new LdapCredentials(); + credentials.activate(credentialsConfig()); + roles = new LdapRoleProvider(); + roles.activate(rolesConfig()); + } + + @Test + void theRightPasswordIsAccepted() { + Optional alice = credentials.verify("alice", "alicepw".toCharArray()); + + assertThat(alice).isPresent(); + assertThat(alice.get().name()).isEqualTo("alice"); + assertThat(alice.get().claims().first(AuthClaims.LDAP_DN)).isEqualTo("cn=alice," + USERS); + } + + @Test + void theWrongPasswordIsNot() { + assertThat(credentials.verify("alice", "wrong".toCharArray())).isEmpty(); + } + + @Test + void anEmptyPasswordIsRefusedBeforeItReachesTheDirectory() { + // The directory would take this as an anonymous bind and answer success. + assertThat(credentials.verify("alice", new char[0])).isEmpty(); + } + + @Test + void anUnknownUserIsNot() { + assertThat(credentials.verify("mallory", "whatever".toCharArray())).isEmpty(); + } + + @Test + void theGroupsAreRead() { + AuthenticatedIdentity alice = credentials.verify("alice", "alicepw".toCharArray()).orElseThrow(); + + Set granted = roles.rolesOf(alice.principal(), alice.claims()); + + assertThat(granted).contains("analysts"); + } + + @Test + void aNameThatBreaksTheFilterMatchesNothing() { + // Unescaped, this turns (member={0}) into a filter matching every group in the + // directory - and with no role mapping in front, every group name would become + // a catalog role. The name can come from a token claim the caller chose. + AuthenticatedIdentity mallory = AuthenticatedIdentity.of(() -> "x)(|(cn=*", Claims.none()); + + assertThat(roles.rolesOf(mallory.principal(), mallory.claims())).isEmpty(); + } + + @Test + void aNameThatBreaksADistinguishedNameDoesNotAuthenticate() { + assertThat(credentials.verify("alice,ou=elsewhere", "alicepw".toCharArray())).isEmpty(); + } + + @Test + void aCallerWhoArrivedWithoutAnEntryStillGetsGroups() { + // Bearer, Negotiate and proxy-header callers carry no distinguished name, so + // the entry has to be looked up or they silently hold no groups at all. + AuthenticatedIdentity fromToken = AuthenticatedIdentity.of(() -> "alice", Claims.none()); + + assertThat(roles.rolesOf(fromToken.principal(), fromToken.claims())).contains("analysts"); + } + + @Test + void aTokenCannotChooseWhichEntryTheGroupsComeFrom() { + // A "dn" claim from a token lands in the jwt namespace and is not the one this + // provider steers by. + Claims forged = Claims.in(AuthClaims.NS_JWT).put(AuthClaims.DN, "cn=bob," + USERS).build(); + AuthenticatedIdentity mallory = AuthenticatedIdentity.of(() -> "alice", forged); + + assertThat(roles.rolesOf(mallory.principal(), mallory.claims())).contains("analysts"); + } + + @Test + void anUnprotectedConnectionHasToBeConfirmed() { + assertThatThrownBy(() -> new LdapCredentials().activate(new LdapCredentials.Config() { + + @Override + public Class annotationType() { + return LdapCredentials.Config.class; + } + + @Override + public String url() { + return LdapStoreTest.this.url(); + } + + @Override + public String userDnPattern() { + return "cn={0}," + USERS; + } + + @Override + public String userSearchBase() { + return USERS; + } + + @Override + public String userSearchFilter() { + return "(cn={0})"; + } + + @Override + public String serviceBindDn() { + return ""; + } + + @Override + public String serviceBindPassword() { + return ""; + } + + @Override + public TlsMode transportSecurity() { + return TlsMode.NONE; + } + + @Override + public boolean allowUnencrypted() { + return false; + } + + @Override + public int connectTimeoutMillis() { + return 5000; + } + + @Override + public int readTimeoutMillis() { + return 10000; + } + + @Override + public String referral() { + return "follow"; + } + })).isInstanceOf(IllegalStateException.class); + } + + @Test + void aCallerInNoGroupGetsNoRoles() { + AuthenticatedIdentity nobody = AuthenticatedIdentity.of(() -> "nobody", + Claims.in(AuthClaims.NS_LDAP).put(AuthClaims.DN, "cn=nobody," + USERS).build()); + + assertThat(roles.rolesOf(nobody.principal(), nobody.claims())).isEmpty(); + } + + private LdapCredentials.Config credentialsConfig() { + return new LdapCredentials.Config() { + + @Override + public Class annotationType() { + return LdapCredentials.Config.class; + } + + @Override + public String url() { + return LdapStoreTest.this.url(); + } + + @Override + public String userDnPattern() { + return "cn={0}," + USERS; + } + + @Override + public String userSearchBase() { + return USERS; + } + + @Override + public String userSearchFilter() { + return "(cn={0})"; + } + + @Override + public String serviceBindDn() { + return "cn=admin," + ROOT; + } + + @Override + public String serviceBindPassword() { + return "adminpw"; + } + + @Override + public TlsMode transportSecurity() { + return TlsMode.NONE; + } + + @Override + public boolean allowUnencrypted() { + // A throwaway container on loopback; nothing here is a real password. + return true; + } + + @Override + public int connectTimeoutMillis() { + return 5000; + } + + @Override + public int readTimeoutMillis() { + return 10000; + } + + @Override + public String referral() { + return "follow"; + } + }; + } + + private LdapRoleProvider.Config rolesConfig() { + return new LdapRoleProvider.Config() { + + @Override + public Class annotationType() { + return LdapRoleProvider.Config.class; + } + + @Override + public String url() { + return LdapStoreTest.this.url(); + } + + @Override + public String groupSearchBase() { + return USERS; + } + + @Override + public String groupSearchFilter() { + return "(member={0})"; + } + + @Override + public String groupNameAttribute() { + return "cn"; + } + + @Override + public String memberOfAttribute() { + return ""; + } + + @Override + public String userSearchBase() { + return USERS; + } + + @Override + public String userSearchFilter() { + return "(cn={0})"; + } + + @Override + public String serviceBindDn() { + return "cn=admin," + ROOT; + } + + @Override + public String serviceBindPassword() { + return "adminpw"; + } + + @Override + public TlsMode transportSecurity() { + return TlsMode.NONE; + } + + @Override + public boolean allowUnencrypted() { + // A throwaway container on loopback; nothing here is a real password. + return true; + } + + @Override + public int connectTimeoutMillis() { + return 5000; + } + + @Override + public int readTimeoutMillis() { + return 10000; + } + + @Override + public String referral() { + return "follow"; + } + }; + } +} diff --git a/server/pom.xml b/server/pom.xml index 6108dc46..fe815909 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -25,8 +25,6 @@ pom adapter.emf - - jdk.httpserver whiteboard.servlet diff --git a/spec/[MS-SSAS].pdf b/spec/[MS-SSAS]_37.pdf similarity index 69% rename from spec/[MS-SSAS].pdf rename to spec/[MS-SSAS]_37.pdf index 40786794..ffac7b13 100644 Binary files a/spec/[MS-SSAS].pdf and b/spec/[MS-SSAS]_37.pdf differ diff --git a/spec/xmlaV11-20021120-1.pdf b/spec/xmlaV11-20021120-1.pdf new file mode 100644 index 00000000..55b708f8 Binary files /dev/null and b/spec/xmlaV11-20021120-1.pdf differ