Skip to content

Commit c542bce

Browse files
committed
Application password support, some other minor fixes
For rm only, removed sending of header net.jazz.jfs.owning-context - doesn't seem to be needed for 7.x More prep for type system quality checker, but no user code yet
1 parent e3dc86b commit c542bce

26 files changed

+2071
-1621
lines changed

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
What's New?
1414
===========
1515

16+
16-Dec-2024
17+
* Authentication using Application passwords now works - for OIDC and SAML-backed authentication providers (OP) - but note below how this has been implemented to perhaps work around the fact that application passwords only work with a single app, os if you want to talk to more than one app, e.g. rm and gc, you have to acquire and specify a password per app. Not extensively tested, please let me know if it works/doesn't work for you!.
18+
1619
02-Dec-2024
1720
* Added examples dncompare, validate, trsreader (this is VERY UNFINISHED!) - see the code in the examples folder
1821
* Fixed oslcquery working from a specific config using -F
@@ -36,7 +39,7 @@ What's New?
3639
Introduction
3740
============
3841

39-
The aim of this code is to provide a Python client for the IBM Enterprise Lifecycle Management (ELM) applications.
42+
The aim of this code is to provide a Python client for the IBM Enterprise Lifecycle Management (ELM) applications, providing a demonstrator of using the APIs.
4043

4144
IMPORTANT NOTES:
4245
* This code is not developed, delivered or supported in any way as part of the IBM ELM applications
@@ -56,7 +59,7 @@ Installation
5659

5760
Either method of install described below installs the elmclient package and puts example commands (such as `oslcquery` into your path so a) they can be run simply by typing the command, e.g. `oslcquery` and b) as you edit the source code these commands automatically use the latest code.
5861

59-
Requirements: Python 3.11/3.10/3.9 - NOTE I'm developing using Python 3.11.4 and compatibility with older versions is NOT checked.
62+
Requirements: Python 3.11/3.10/3.9 - NOTE I'm developing using Python 3.11.4 and compatibility with older versions is NOT checked. However I'm aiming to work back to 3.9 because that's embedded in e.g. RHEL 9.4.
6063

6164
Overview
6265
--------
@@ -75,7 +78,7 @@ Step 2a - Quickest and easiest to just use elmclient
7578

7679
This method is also easiest to update with new versions of elmclient.
7780

78-
at a command prompt:
81+
At a command prompt:
7982
* for Windows type `pip install elmclient`
8083
* For *nix use `pip3 install elmclient`
8184

@@ -122,13 +125,19 @@ Authentication (in httpops.py)
122125
The auth code works with:
123126
* form authentication using Liberty in local user registry
124127
* LDAP (using JTS setup for LDAP) and OIDC (Jazz Authorisation Server, which might be configured for LDAP)
128+
* Application passwords backed by SAML or OIDC OP
125129

126130
Other authentication methods haven't been tested.
127131

128132
You'll have to provide a username and password; that username will determine the permissions to read/write data on your server, just as they would through a browser UI.
129133

130134
The examples `oslcquery` and `reqif_io` layer authentication enhancements on top of this to allow saving obfuscated credentials to a file so you don't have to provide these on the commandline every time. See the code for these examples.
131135

136+
As of 16-Dec you can now use application passwords. These authenticate to a single app, but it's easy to imagine needed to talk to e.g. GC (on/gc) and RM (on /rm), so the support is implemented by encoding one or more application passwords in the "password".
137+
138+
The password you use when using application passwords has a prefix ap: and then a comma-seperated list of one or more application passwords (specifying the contextroot:password) and then finally a non-application password. So for example if you want to use an application password AP1 with context root rm, your password would look like ap:rm:AP1. If you also want to talk to GC on /gc and to anything else using your non-ap password then use ap:rm:AP1,gc:AP2,mypassword
139+
140+
This hasn't been extensively tested. Please let me know in the Issues if this works for you or has problems.
132141

133142
Handling different context roots
134143
================================
@@ -137,7 +146,7 @@ It's possible to install the ELM applications to run on non-standard context roo
137146

138147
For example, if your DN is on /rm then just specify `rm`. Or, if it's on /rm23 then specify `rm:rm23`.
139148

140-
If more than one application is needed then use a comma separate list (without spaces). The main application is specified first, but if jts is also on /jts1 then your APPSTRING could be `rm:rm1,jts:jts1`.
149+
If more than one application is needed then use a comma separate list (without spaces). The main application is specified first, and if jts is also needed on /jts1 then your APPSTRING could be `rm:rm1,jts:jts1`.
141150

142151

143152
Example code provided

elmclient/_newtypesystem.py

Lines changed: 358 additions & 103 deletions
Large diffs are not rendered by default.

elmclient/_rm.py

Lines changed: 140 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,15 @@
2525
from . import rdfxml
2626
from . import server
2727
from . import utils
28+
from . import _newtypesystem
2829

29-
30+
# used for OSLC Query on types
31+
typeresources = {
32+
'http://jazz.net/ns/rm/dng/types#ArtifactType': ('ArtifactType' ,'OT'),
33+
'http://jazz.net/ns/rm/dng/types#AttributeDefinition': ('AttributeDefinition','AD'),
34+
'http://jazz.net/ns/rm/dng/types#AttributeType': ('AttributeType' ,'AT'),
35+
'http://jazz.net/ns/rm/dng/types#LinkType': ('LinkType' ,'LT'),
36+
}
3037

3138

3239
#################################################################################################
@@ -275,6 +282,15 @@ def create_folder( self, path ):
275282

276283
def delete_folder( self, name_or_uri ):
277284
raise Exception( "Folder delete not implemented! Left as an exercise for the user" )
285+
286+
def _get_headers(self, headers=None):
287+
logger.info( f"rmp gh {headers=}" )
288+
result = super()._get_headers()
289+
# result['net.jazz.jfs.owning-context'] = self.baseurl
290+
if headers:
291+
result.update(headers)
292+
logger.info( f"rmp gh {result=}" )
293+
return result
278294

279295
def load_components_and_configurations(self,force=False, cacheable=True):
280296
if self._components and not force:
@@ -487,11 +503,11 @@ def add_external_component(self,compu):
487503
self._components[compu]['component'] = c
488504
return c
489505

490-
def load_configs(self, cacheable=True):
506+
def load_configs(self, cacheable=True ):
491507
logger.debug( f"Loading configs {self._confs_to_load=}" )
492508
# load configurations
493509
# and build a tree with initial baseline as root, alternating baseline and stream nodes each with a list of children, so it can be walked if needed
494-
self.configTree = anytree.AnyNode(name='theroot',textname='root', created=None, typesystem=None, ismutable=False, ischangeset=False )
510+
self.configTree = anytree.AnyNode(name='theroot',title='root', created=None, typesystem=None, ismutable=False, ischangeset=False )
495511
confstoparent = []
496512
while True:
497513
if not self._confs_to_load:
@@ -545,6 +561,7 @@ def load_configs(self, cacheable=True):
545561
if confmember_x.tag == '{http://open-services.net/ns/config#}ChangeSet':
546562
conftype = "ChangeSet"
547563
ischangeset=True
564+
ismutable=True
548565
elif confmember_x.tag == '{http://open-services.net/ns/config#}Baseline':
549566
conftype = "Baseline"
550567
elif confmember_x.tag == '{http://open-services.net/ns/config#}Stream' or rdfxml.xmlrdf_get_resource_uri( confmember_x,'.//rdf:type[@rdf:resource="http://open-services.net/ns/config#Stream"]') is not None:
@@ -568,7 +585,7 @@ def load_configs(self, cacheable=True):
568585
# print( f"Adding {conftitle}" )
569586
self._configurations[thisconfu] = {
570587
'name': conftitle
571-
, 'conftype': conftype
588+
,'conftype': conftype
572589
,'confXml': confmember_x
573590
,'created': created
574591
}
@@ -592,7 +609,7 @@ def load_configs(self, cacheable=True):
592609
else:
593610
# need to find the parent to attach to
594611
# create the node - if we don't attach it now we'll attach it later - typesystem is set to None so if needed this can be filled in later.
595-
thisnode = anytree.AnyNode( None, name=thisconfu,textname=conftitle, created=created, typesystem=None ) #TypeSystem(conftitle, thisconfu), ismutable=ismutable, ischangeset=ischangeset )
612+
thisnode = anytree.AnyNode( None, name=thisconfu, title=conftitle, conftype=conftype, created=created, typesystem=_newtypesystem.TypeSystem(conftitle, thisconfu), ismutable=ismutable ) #TypeSystem(conftitle, thisconfu), ismutable=ismutable, ischangeset=ischangeset )
596613
if parentnode is None:
597614
# do this one later
598615
confstoparent.append( ( thisnode, theparent_u ) )
@@ -619,8 +636,63 @@ def load_configs(self, cacheable=True):
619636
newconfstoparent.append( (node,parent) )
620637
confstoparent = newconfstoparent
621638

622-
# # show the config tree
623-
# print( f"tree= {anytree.RenderTree(self.configTree, style=anytree.AsciiStyle())}" )
639+
640+
def load_configtree( self, *, fromconfig_u=None, loadbaselines=False, followsubstreams=False, loadchangesets=False, alwayscaching=False ):
641+
# show the config tree
642+
print( f"tree= {anytree.RenderTree(self.configTree, style=anytree.AsciiStyle())}" )
643+
print( f"{self.configTree=}" )
644+
print( f"{self.configTree.children=}" )
645+
if not fromconfig_u:
646+
fromconfig_u = self.configTree.children[0].name
647+
print( f"{fromconfig_u=}" )
648+
startnode = anytree.search.find( self.configTree, filter_=lambda n: n.name==fromconfig_u )
649+
650+
for conf in anytree.iterators.preorderiter.PreOrderIter( startnode ):
651+
# load the typesystem for this node
652+
if conf is None or conf.name is None or not conf.name.startswith( 'http'):
653+
# print( f"Ignoring {conf}" )
654+
continue
655+
if conf.conftype == "Changeset" and not loadchangesets:
656+
continue
657+
if conf.conftype == "Baseline":
658+
if not loadbaselines and not followsubstreams and conf.name != fromconfig_u:
659+
# remember the substreams and come back to load them
660+
continue
661+
if not loadbaselines:
662+
continue
663+
664+
print( f"------------------------------\n'{conf.title}' {conf.ismutable=} {conf.created} {conf.name}" )
665+
# print( f"{conf.children=}" )
666+
self.set_local_config(conf.name)
667+
# continue
668+
# GET the typesystem - caching is determinded by ismutable
669+
#typeresources = {
670+
# 'http://jazz.net/ns/rm/dng/types#ArtifactType': ('ArtifactType' ,'OT'),
671+
for resourcetype,typedetails in typeresources.items():
672+
# QUERY to get the types
673+
# print( f"Getting {typedetails[0]} {typedetails[1]=}" )
674+
# print( f"{alwayscaching or not conf.ismutable=}" )
675+
results = self.do_complex_query( resourcetype, querystring=None, select="*",show_progress=False,cacheable=alwayscaching or not conf.ismutable )
676+
# print( f"{results=}" )
677+
for k,v in results.items():
678+
# print( f" result {k=} {v=}" )
679+
if not self.app.is_server_uri( k ):
680+
# ignore non-local references
681+
# print( f"Ignoring non-local {typedetails[1]} {k}" )
682+
continue
683+
if typedetails[1]=='OT':
684+
# find the attributes and record them
685+
conf.typesystem.load_ot( self, k, iscacheable=alwayscaching or not conf.ismutable, isused=True )
686+
elif typedetails[1]=='AD':
687+
conf.typesystem.load_ad( self, k, iscacheable=alwayscaching or not conf.ismutable, isused=False )
688+
elif typedetails[1]=='AT':
689+
# print( f"Loading AT {k=}" )
690+
conf.typesystem.load_at( self, k, iscacheable=alwayscaching or not conf.ismutable, isused=False )
691+
elif typedetails[1]=='LT':
692+
# print( f"Loading LT {k=}" )
693+
conf.typesystem.load_lt( self, k, iscacheable=alwayscaching or not conf.ismutable, isused=False )
694+
else:
695+
raise Exception( f"Unkown type {typedetails[1]}" )
624696

625697

626698
def get_local_config(self, name_or_uri, global_config_uri=None):
@@ -863,7 +935,7 @@ def type_name_from_uri(self, uri):
863935
# retrieve the definition
864936
resource_xml = self.execute_get_rdf_xml(reluri=uri, intent="Retrieve type RDF to get its name")
865937
# check for a rdf label (used for links, maybe other things)
866-
id = rdfxml.xmlrdf_get_resource_text(resource_xml,".//rdf:Property/rdfs:label") or rdfxml.xmlrdf_get_resource_text(resource_xml,".//oslc:ResourceShape/dcterms:title") or rdfxml.xmlrdf_get_resource_text(resource_xml,f'.//rdf:Description[@rdf:about="{uri}"]/rdfs:label')
938+
id = rdfxml.xmlrdf_get_resource_text(resource_xml,".//rdf:Property/rdfs:label") or rdfxml.xmlrdf_get_resource_text(resource_xml,".//oslc:ResourceShape/dcterms:title") or rdfxml.xmlrdf_get_resource_text(resource_xml,f'.//rdf:Description[@rdf:about="{uri}"]/rdfs:label') or rdfxml.xmlrdf_get_resource_text(resource_xml,f'.//dng_types:LinkType/rdfs:label')
867939
if id is None:
868940
id = f"STRANGE TYPE {uri}"
869941
raise Exception( f"No type for {uri=}" )
@@ -1193,14 +1265,15 @@ def __init__(self, server, contextroot, jts=None):
11931265
self.version = rdfxml.xmlrdf_get_resource_text(self.rootservices_xml,'.//oslc_rm_10:version')
11941266
self.majorversion = rdfxml.xmlrdf_get_resource_text(self.rootservices_xml,'.//oslc_rm_10:majorVersion')
11951267
# self.reportablerestbase = 'publish'
1196-
self.default_query_resource = None # RM doesn't provide any app-level queries
1268+
self.rmcmServiceProviders = "oslc:details"
1269+
self.default_query_resource = 'http://open-services.net/ns/config#Configuration' # pre-7.1 RM didn't provide any app-level queries
11971270

11981271
logger.info( f"Versions {self.majorversion} {self.version}" )
11991272

12001273
def _get_headers(self, headers=None):
12011274
logger.info( f"rm gh {headers=}" )
12021275
result = super()._get_headers()
1203-
result['net.jazz.jfs.owning-context'] = self.baseurl
1276+
# result['net.jazz.jfs.owning-context'] = self.baseurl
12041277
if headers:
12051278
result.update(headers)
12061279
logger.info( f"rmapp_gh {result}" )
@@ -1233,7 +1306,7 @@ def _load_types(self,force=False):
12331306
sx = self.retrieve_oslc_catalog_xml()
12341307
if sx:
12351308
shapes_to_load = rdfxml.xml_find_elements(sx, './/oslc:resourceShape')
1236-
print( f"{shapes_to_load=}" )
1309+
# print( f"{shapes_to_load=}" )
12371310

12381311
pbar = tqdm.tqdm(initial=0, total=len(shapes_to_load),smoothing=1,unit=" results",desc="Loading ERM/DN shapes")
12391312

@@ -1248,6 +1321,62 @@ def _load_types(self,force=False):
12481321
self.typesystem_loaded = True
12491322
return None
12501323

1324+
# RM has to find app-wide query capabilities differently from ETM/GCM - the XML with the QueryCapability is in the component RDF
1325+
# see https://jazz.net/wiki/bin/view/Main/DNGOSLCConfigurationQueryCapabilityOverview
1326+
def retrieve_rm_cm_service_provider_xml(self):
1327+
cm_service_provider_uri = rdfxml.xmlrdf_get_resource_uri( self.rootservices_xml, self.cmServiceProviders )
1328+
rdfcomponent = self.execute_get_rdf_xml( cm_service_provider_uri, intent="Retrieve application CM Service Provider" )
1329+
rm_cm_service_provider_uri = rdfxml.xmlrdf_get_resource_uri( rdfcomponent, f".//{self.rmcmServiceProviders}" )
1330+
rdf = self.execute_get_rdf_xml( rm_cm_service_provider_uri, intent="Retrieve RM CM Service Provider" )
1331+
return rdf
1332+
1333+
def get_query_capability_uri(self,resource_type=None,context=None):
1334+
context = context or self
1335+
resource_type = resource_type or context.default_query_resource
1336+
return self.get_query_capability_uri_from_xml( capabilitiesxml=context.retrieve_rm_cm_service_provider_xml(), resource_type=resource_type, context=context )
1337+
1338+
# given a type URI, return its name
1339+
def resolve_uri_to_name(self, uri, prefer_same_as=True, dontpreferhttprdfrui=True):
1340+
logger.info( f"resolve_uri_to_name {uri=}" )
1341+
if not uri:
1342+
result = None
1343+
return result
1344+
if not uri.startswith('http://') or not uri.startswith('https://'):
1345+
# try to remove prefix
1346+
uri1 = rdfxml.tag_to_uri(uri,noexception=True)
1347+
logger.debug(f"Trying to remove prefix {uri=} {uri1=}")
1348+
if uri1 is None:
1349+
return uri
1350+
if uri1 != uri:
1351+
logger.debug( f"Changed {uri} to {uri1}" )
1352+
else:
1353+
logger.debug( f"NOT Changed {uri} to {uri1}" )
1354+
# use the transformed URI
1355+
uri = uri1
1356+
if not uri.startswith(self.reluri()):
1357+
if self.server.jts.is_user_uri(uri):
1358+
result = self.server.jts.user_uritoname_resolver(uri)
1359+
logger.debug(f"returning user")
1360+
return result
1361+
uri1 = rdfxml.uri_to_prefixed_tag(uri,noexception=True)
1362+
logger.debug(f"No app base URL {self.reluri()=} {uri=} {uri1=}")
1363+
return uri1
1364+
elif not self.is_known_uri(uri):
1365+
if self.server.jts.is_user_uri(uri):
1366+
result = self.server.jts.user_uritoname_resolver(uri)
1367+
else:
1368+
if uri.startswith( "http://" ) or uri.startswith( "https://" ):
1369+
uri1 = rdfxml.uri_to_prefixed_tag(uri)
1370+
logger.debug( f"Returning the raw URI {uri} so changed it to prefixed {uri1}" )
1371+
uri = uri1
1372+
result = uri
1373+
# ensure the result is in the types cache, in case it recurrs the result can be pulled from the cache
1374+
self.register_name(result,uri)
1375+
else:
1376+
result = self.get_uri_name(uri)
1377+
logger.info( f"Result {result=}" )
1378+
return result
1379+
12511380
@classmethod
12521381
def add_represt_arguments( cls, subparsers, common_args ):
12531382
'''

elmclient/examples/batchquery.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
,'Value1': '-v'
4848
,'OutputFile': '-O'
4949
,'TypeSystemReport': '--typesystemreport'
50+
,'Browser': '-B'
5051
,'Creds0': '-0'
5152
,'Creds1': '-1'
5253
,'Creds2': '-2'

elmclient/examples/oslcquery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ def safeint(s,nonereturns=0):
703703
for k, v in results.items():
704704
htmlfile.write( "<tr>" )
705705
for fieldname in fieldnames:
706-
htmlfile.write( f"<td>{v.get( fieldname,"" )}</td>" )
706+
htmlfile.write( f"<td>{v.get( fieldname,'' )}</td>" )
707707
htmlfile.write( "</tr>" )
708708
htmlfile.write( "</table>" )
709709
htmlfile.write( "</body></html>" )

0 commit comments

Comments
 (0)