Compare commits
	
		
			15 commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2f13094bbb | |||
| b85425df6f | |||
| 3b2b25226d | |||
| bf617e12b0 | |||
| e8f51cb41a | |||
| 573d4f0198 | |||
| f100a18f22 | |||
| bf2d8a1e99 | |||
| f66ef8ee5c | |||
| 06098265d1 | |||
| cbbc8a3b87 | |||
| 0fb0ba0c74 | |||
| 0985f1b886 | |||
| ec99c62348 | |||
| 71fc565a7e | 
					 41 changed files with 113 additions and 1074 deletions
				
			
		
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -2,14 +2,12 @@ | ||||||
| *.pyo | *.pyo | ||||||
| *.egg-info | *.egg-info | ||||||
| *.project | *.project | ||||||
| *.log |  | ||||||
| *.swp | *.swp | ||||||
| *.pydevproject | *.pydevproject | ||||||
| *.sublime-project | *.sublime-project | ||||||
| *.sublime-workspace | *.sublime-workspace | ||||||
| *.ropeproject | *.ropeproject | ||||||
| .env | .env | ||||||
| .private* |  | ||||||
| .pytest.ini | .pytest.ini | ||||||
| *#*# | *#*# | ||||||
| *.#* | *.#* | ||||||
|  |  | ||||||
|  | @ -1,48 +0,0 @@ | ||||||
| # py-scopes/demo/config.py |  | ||||||
| 
 |  | ||||||
| from dotenv import load_dotenv |  | ||||||
| import logging |  | ||||||
| from os import getenv |  | ||||||
| from scopes.web.app import zope_app_factory |  | ||||||
| 
 |  | ||||||
| load_dotenv() |  | ||||||
| 
 |  | ||||||
| log_file = 'log/scopes.log' |  | ||||||
| log_level = logging.DEBUG |  | ||||||
| log_format = '%(asctime)s %(levelname)s %(name)s %(message)s' |  | ||||||
| log_dateformat = '%Y-%m-%dT%H:%M:%S' |  | ||||||
| logging.basicConfig(filename=log_file, level=log_level,  |  | ||||||
|                     format=log_format, datefmt=log_dateformat) |  | ||||||
| 
 |  | ||||||
| server_port = getenv('SERVER_PORT', '8099') |  | ||||||
| base_url = getenv('BASE_URL', 'https://demo.cy7.de') |  | ||||||
| 
 |  | ||||||
| app_factory = zope_app_factory |  | ||||||
| 
 |  | ||||||
| # storage settings |  | ||||||
| from scopes.storage.db.postgres import StorageFactory |  | ||||||
| dbengine = 'postgresql+psycopg' |  | ||||||
| dbname = getenv('DBNAME', 'demo') |  | ||||||
| dbuser = getenv('DBUSER', 'demo') |  | ||||||
| dbpassword = getenv('DBPASSWORD', 'secret') |  | ||||||
| dbschema = getenv('DBSCHEMA', 'demo') |  | ||||||
| 
 |  | ||||||
| # authentication settings |  | ||||||
| oidc_provider = getenv('OIDC_PROVIDER', 'https://a1.cy7.de') |  | ||||||
| oidc_client_id = getenv('OIDC_CLIENT_ID', '12345') |  | ||||||
| oidc_params = dict( |  | ||||||
|     op_config_url=oidc_provider + '/.well-known/openid-configuration', |  | ||||||
|     op_uris=None, |  | ||||||
|     op_keys=None, |  | ||||||
|     op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud', |  | ||||||
|     callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'), |  | ||||||
|     client_id = oidc_client_id, |  | ||||||
|     cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id), |  | ||||||
|     cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None), |  | ||||||
|     cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'), |  | ||||||
|     cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None), |  | ||||||
|     private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE', '.private-key.json'), |  | ||||||
|     organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'), |  | ||||||
|     project_id=getenv('OIDC_PROJECT_ID', None), |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| # py-scopes/demo/demo_server.py |  | ||||||
| 
 |  | ||||||
| from scopes.web.auth import oidc |  | ||||||
| from scopes.storage import topic |  | ||||||
| 
 |  | ||||||
| import logging |  | ||||||
| import waitress |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def run(app, config): |  | ||||||
|     oidc.startup()  # todo: use generic app.startServices() |  | ||||||
|     port = int(config.server_port) |  | ||||||
|     print(f'Serving on port {port}.') |  | ||||||
|     waitress.serve(app, port=port) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| if __name__ == '__main__': |  | ||||||
|     import config |  | ||||||
|     app = config.app_factory(config) |  | ||||||
|     run(app, config) |  | ||||||
|     # see zope.app.wsgi.getWSGIApplication |  | ||||||
							
								
								
									
										17
									
								
								demo/env.in
									
										
									
									
									
								
							
							
						
						
									
										17
									
								
								demo/env.in
									
										
									
									
									
								
							|  | @ -1,17 +0,0 @@ | ||||||
| # s10: py-scopes/demo/.env |  | ||||||
| # input (example) file - copy to .env and edit. |  | ||||||
| 
 |  | ||||||
| SERVER_PORT=8800 |  | ||||||
| BASE_URL=https://demo.cy7.de |  | ||||||
| 
 |  | ||||||
| DBNAME=demo |  | ||||||
| DBUSER=demo |  | ||||||
| DBPASSWORD=secret |  | ||||||
| DBSCHEMA=demo |  | ||||||
| 
 |  | ||||||
| OIDC_PROVIDER= |  | ||||||
| OIDC_CLIENT_ID= |  | ||||||
| OIDC_COOKIE_CRYPT= |  | ||||||
| OIDC_ORGANIZATION_ID= |  | ||||||
| OIDC_PROJECT_ID= |  | ||||||
| 
 |  | ||||||
|  | @ -1,2 +0,0 @@ | ||||||
| directory for logfiles created by application |  | ||||||
| 
 |  | ||||||
|  | @ -1,11 +0,0 @@ | ||||||
| # scopes/demo/shell.py |  | ||||||
| # simple shell for interactive testing / accessing the database (storage) |  | ||||||
| # use: `python -i shell.py` |  | ||||||
| 
 |  | ||||||
| import config |  | ||||||
| from scopes.web.auth import oidc |  | ||||||
| from scopes.storage.folder import Root |  | ||||||
| from scopes.storage import topic |  | ||||||
| 
 |  | ||||||
| storage = config.StorageFactory(config)(config.dbschema) |  | ||||||
| root = Root(storage) |  | ||||||
|  | @ -1,43 +0,0 @@ | ||||||
| [build-system] |  | ||||||
| requires = ["setuptools"] |  | ||||||
| build-backend = "setuptools.build_meta" |  | ||||||
| 
 |  | ||||||
| [project] |  | ||||||
| name = "py-scopes" |  | ||||||
| version = "3.0.1" |  | ||||||
| description = "Implementation of the unknown 'scopes' paradigm in Python" |  | ||||||
| readme = "README.md" |  | ||||||
| license = {text = "MIT"} |  | ||||||
| keywords = ["scopes"] |  | ||||||
| authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}] |  | ||||||
| 
 |  | ||||||
| dependencies = [ |  | ||||||
| 	"SQLAlchemy", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [project.optional-dependencies] |  | ||||||
| postgres = [ |  | ||||||
| 	"psycopg[binary]", |  | ||||||
| 	"transaction", |  | ||||||
| 	"zope.sqlalchemy", |  | ||||||
| ] |  | ||||||
| app = [ |  | ||||||
| 	"python-dotenv",  |  | ||||||
| 	"waitress", |  | ||||||
| 	"zope.authentication", |  | ||||||
| 	"zope.interface", |  | ||||||
| 	"zope.publisher",  |  | ||||||
| 	"zope.traversing", |  | ||||||
| ] |  | ||||||
| auth = ["pyjwt[crypto]", "cryptography", "requests"] |  | ||||||
| test = ["zope.testrunner"] |  | ||||||
| #test = ["pytest"] |  | ||||||
| 
 |  | ||||||
| [tool.setuptools] |  | ||||||
| packages = ["scopes"] |  | ||||||
| 
 |  | ||||||
| #[tool.pytest.ini_options] |  | ||||||
| #addopts = "-vv" |  | ||||||
| #python_files = "test_standard.py" # default: run only `standard` tests |  | ||||||
| # use .pytest.ini file with `python_files = test_*.py` to run all tests |  | ||||||
| 
 |  | ||||||
|  | @ -1,5 +0,0 @@ | ||||||
| # runtests.sh |  | ||||||
| # run all unit / doc tests |  | ||||||
| 
 |  | ||||||
| zope-testrunner --test-path=. $* |  | ||||||
| 
 |  | ||||||
|  | @ -1,14 +1,9 @@ | ||||||
| # scopes.interfaces | # scopes.interfaces | ||||||
| 
 | 
 | ||||||
| from zope.interface import Interface, Attribute | from zope.interface import Interface | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class IViewable(Interface): | class ITraversable(Interface): | ||||||
| 
 |  | ||||||
|     prefix = Attribute('Prefix string for identifying the type (class) of an object') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ITraversable(IViewable): |  | ||||||
| 
 | 
 | ||||||
|     def get(key, default=None): |     def get(key, default=None): | ||||||
|         """Return the item addressed by `key`; return `default` if not found.""" |         """Return the item addressed by `key`; return `default` if not found.""" | ||||||
|  |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| """package scopes.org""" |  | ||||||
|  | @ -1,16 +0,0 @@ | ||||||
| # scopes.org.mail |  | ||||||
| 
 |  | ||||||
| from zope.interface import implementer |  | ||||||
| from zope.sendmail.interfaces import IMailDelivery |  | ||||||
| 
 |  | ||||||
| """Utilities for creating and sending emails.""" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @implementer(IMailDelivery) |  | ||||||
| class DummyMailDelivery: |  | ||||||
|     """For testing purposes: just store mails in maildata.log""" |  | ||||||
| 
 |  | ||||||
|     def send(self, fromaddr, toaddrs, message): |  | ||||||
|         print("DummyMailDelivery") |  | ||||||
|         print(f"fromaddr: {fromaddr}, toaddrs: {toaddrs}") |  | ||||||
|         print(message) |  | ||||||
|  | @ -1,95 +0,0 @@ | ||||||
| # scopes.org.user |  | ||||||
| 
 |  | ||||||
| """Basic user account (principal) definitions + access to identity provider.""" |  | ||||||
| 
 |  | ||||||
| from dataclasses import dataclass, field |  | ||||||
| from typing import List, Optional |  | ||||||
| 
 |  | ||||||
| from scopes.web import client |  | ||||||
| from scopes import util |  | ||||||
| 
 |  | ||||||
| import config |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @dataclass |  | ||||||
| class User: |  | ||||||
| 
 |  | ||||||
|     login: str |  | ||||||
|     email: str |  | ||||||
|     hashedPassword: Optional[str] = None |  | ||||||
|     firstName: str = '' |  | ||||||
|     lastName: str = '' |  | ||||||
|     displayName: str = '' |  | ||||||
|     groups: List[str] = field(default_factory=list) |  | ||||||
| 
 |  | ||||||
|     def __post_init__(self): |  | ||||||
|         if not self.displayName: |  | ||||||
|             self.displayName = ' '.join((self.firstName, self.lastName)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ExtUser: |  | ||||||
|     """All infos for exchanging user data with an external service. |  | ||||||
| 
 |  | ||||||
|        This base class implements the zitadel interface (as of version 3.3.2).  |  | ||||||
|        For other identity providers sublass accordingly. |  | ||||||
|     """ |  | ||||||
| 
 |  | ||||||
|     provider = 'zitatel' |  | ||||||
|     endpoints = dict( |  | ||||||
|             users_human='v2/users/human', |  | ||||||
|             #create_authorization='management/v1/zitadel.authorization.v2beta.AuthorizationService/CreateAuthorization', |  | ||||||
|             create_authorization='v2beta/authorizations', |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     def __init__(self, user, idPrefix=''): |  | ||||||
|         self.user = user |  | ||||||
|         self.userId = idPrefix + user.login |  | ||||||
|         self.client = client.ApiClient(config.oidc_provider) |  | ||||||
| 
 |  | ||||||
|     def asDict(self): |  | ||||||
|         params = config.oidc_params |  | ||||||
|         data = dict( |  | ||||||
|             userId=self.userId, |  | ||||||
|             username=self.user.login, |  | ||||||
|             email=dict(email=self.user.email, isVerified=True), |  | ||||||
|             profile=dict( |  | ||||||
|                 givenName=self.user.firstName, |  | ||||||
|                 familyName=self.user.lastName, |  | ||||||
|                 displayName=self.user.displayName, |  | ||||||
|             ), |  | ||||||
|             organization=dict(orgId=params['organization_id']), |  | ||||||
|         ) |  | ||||||
|         return data |  | ||||||
| 
 |  | ||||||
|     def create(self, updateIfExists=False): |  | ||||||
|         data = self.asDict() |  | ||||||
|         if self.user.hashedPassword: |  | ||||||
|             data['hashedPassword'] = self.user.hashedPassword |  | ||||||
|         status, res = self.client.post(self.endpoints['users_human'], data) |  | ||||||
|         if status > 201: |  | ||||||
|             if updateIfExists: |  | ||||||
|                 return self.update() |  | ||||||
|         return status, res |  | ||||||
|         #if self.user.groups: |  | ||||||
|             #return self.createGroups() |  | ||||||
| 
 |  | ||||||
|     def update(self, createIfMissing=False): |  | ||||||
|         data = self.asDict() |  | ||||||
|         if self.user.hashedPassword: |  | ||||||
|             data['password'] = dict(hashedPassword=self.user.hashedPassword) |  | ||||||
|         status, res = self.client.put(self.endpoints['users_human'], self.userId, data) |  | ||||||
|         if status > 200: |  | ||||||
|             if createIfMissing: |  | ||||||
|                 return self.create() |  | ||||||
|             else: |  | ||||||
|                 return status, res |  | ||||||
|         #if self.user.groups: |  | ||||||
|             #return self.updateGroups() |  | ||||||
| 
 |  | ||||||
|     def createGroups(self): |  | ||||||
|         data = dict( |  | ||||||
|                 userId=self.userId, |  | ||||||
|                 projectId=config.oidc_params['project_id'], |  | ||||||
|                 roleKeys=self.user.groups, |  | ||||||
|         ) |  | ||||||
|         return self.client.post(self.endpoints['create_authorization'], data) |  | ||||||
							
								
								
									
										1
									
								
								scopes/organize/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								scopes/organize/__init__.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | """package scopes.organize""" | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| # scopes.org.task | # scopes.organize.task | ||||||
| 
 | 
 | ||||||
| """Task (and corresponding container) implementation.""" | """Task (and corresponding container) implementation.""" | ||||||
| 
 | 
 | ||||||
							
								
								
									
										1
									
								
								scopes/server/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								scopes/server/__init__.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | """package scopes.server""" | ||||||
|  | @ -1,31 +1,22 @@ | ||||||
| # scopes.web.app | # scopes.server.app | ||||||
| 
 | 
 | ||||||
| import logging |  | ||||||
| from zope.i18n.interfaces import IUserPreferredCharsets |  | ||||||
| from zope.interface import implementer |  | ||||||
| from zope.publisher.base import DefaultPublication | from zope.publisher.base import DefaultPublication | ||||||
| from zope.publisher.browser import BrowserRequest | from zope.publisher.browser import BrowserRequest | ||||||
| from zope.publisher.interfaces import NotFound | from zope.publisher.interfaces import NotFound | ||||||
| from zope.publisher.publish import publish | from zope.publisher.publish import publish | ||||||
| 
 | 
 | ||||||
| from scopes.interfaces import ITraversable, IView | from scopes.interfaces import ITraversable, IView | ||||||
| from scopes.web.browser import getView | from scopes.server.browser import getView | ||||||
| import scopes.storage.concept # register container classes | import scopes.storage.concept # register container classes | ||||||
| from scopes.storage.folder import Root | from scopes.storage.folder import Root | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @implementer(IUserPreferredCharsets) |  | ||||||
| class Request(BrowserRequest): |  | ||||||
|     def getPreferredCharsets(self): |  | ||||||
|         return ['UTF-8'] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def zope_app_factory(config): | def zope_app_factory(config): | ||||||
|     storageFactory = config.StorageFactory(config) |     storageFactory = config.StorageFactory(config) | ||||||
|     def zope_app(environ, start_response): |     def zope_app(environ, start_response): | ||||||
|         storage = storageFactory(config.dbschema) |         storage = storageFactory(config.dbschema) | ||||||
|         appRoot = Root(storage) |         appRoot = Root(storage) | ||||||
|         request = Request(environ['wsgi.input'], environ) |         request = BrowserRequest(environ['wsgi.input'], environ) | ||||||
|         request.setPublication(Publication(appRoot)) |         request.setPublication(Publication(appRoot)) | ||||||
|         request = publish(request, True) |         request = publish(request, True) | ||||||
|         response = request.response |         response = request.response | ||||||
|  | @ -36,12 +27,6 @@ def zope_app_factory(config): | ||||||
| 
 | 
 | ||||||
| class Publication(DefaultPublication): | class Publication(DefaultPublication): | ||||||
| 
 | 
 | ||||||
|     def beforeTraversal(self, request): |  | ||||||
|         super(Publication, self).beforeTraversal(request) |  | ||||||
|         from scopes.web.auth.oidc import authentication |  | ||||||
|         prc = authentication.authenticate(request) |  | ||||||
|         request.setPrincipal(prc) |  | ||||||
| 
 |  | ||||||
|     def traverseName(self, request, ob, name): |     def traverseName(self, request, ob, name): | ||||||
|         next = getView(request, ob, name) |         next = getView(request, ob, name) | ||||||
|         if next is not None: |         if next is not None: | ||||||
|  | @ -1,25 +1,21 @@ | ||||||
| # scopes.web.browser | # scopes.server.browser | ||||||
| 
 | 
 | ||||||
| import json | import json | ||||||
| import logging |  | ||||||
| from zope.interface import implementer | from zope.interface import implementer | ||||||
| from scopes.interfaces import IContainer, IReference, IView | from scopes.interfaces import IContainer, IReference, IView | ||||||
| 
 | 
 | ||||||
| logger = logging.getLogger('web.browser') |  | ||||||
| 
 |  | ||||||
| views = {} # registry for all views: {name: {prefix: viewClass, ...}, ...} | views = {} # registry for all views: {name: {prefix: viewClass, ...}, ...} | ||||||
| 
 | 
 | ||||||
| def register(name, *contextTypes): | def register(name, *contextClasses): | ||||||
|     """Use as decorator: `@register(name, class_or_prefix, ...).  |     """Use as decorator: `@register(name, class, ...).  | ||||||
|        No class (or `None` or `''`) means default view for all classes.""" |        class `None` means default view for all classes.""" | ||||||
|     def doRegister(factory): |     def doRegister(factory): | ||||||
|         implementer(IView)(factory) |         implementer(IView)(factory) | ||||||
|         nameEntry = views.setdefault(name, {}) |         nameEntry = views.setdefault(name, {}) | ||||||
|         cts = contextTypes or [''] |         for cl in contextClasses: | ||||||
|         for ct in cts: |             nameEntry[cl.prefix] = factory | ||||||
|             if not isinstance(ct, str): |         else: | ||||||
|                 ct = ct.prefix |             nameEntry[''] = factory | ||||||
|             nameEntry[ct] = factory |  | ||||||
|         return factory |         return factory | ||||||
|     return doRegister |     return doRegister | ||||||
| 
 | 
 | ||||||
|  | @ -32,7 +28,6 @@ def getView(request, ob, name): | ||||||
|         factory = nameEntry.get('') |         factory = nameEntry.get('') | ||||||
|     if factory is None: |     if factory is None: | ||||||
|         return None |         return None | ||||||
|     logger.debug('getView: %s %s', ob, request['PATH_INFO']) |  | ||||||
|     return factory(ob, request) |     return factory(ob, request) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -59,11 +54,10 @@ class DefaultView: | ||||||
|                 result['target'] = target.asDict() |                 result['target'] = target.asDict() | ||||||
|                 if IContainer.providedBy(target): |                 if IContainer.providedBy(target): | ||||||
|                     result['target']['items'] = [v.asDict() for v in target.values()] |                     result['target']['items'] = [v.asDict() for v in target.values()] | ||||||
|         prc = self.request.principal |  | ||||||
|         if prc is not None: |  | ||||||
|             result['principal'] = prc.asDict() |  | ||||||
|         return result |         return result | ||||||
| 
 | 
 | ||||||
|     def render(self, result): |     def render(self, result): | ||||||
|         self.request.response.setHeader('Content-type', 'application/json; charset=utf-8') |         self.request.response.setHeader('Content-type', 'application/json; charset=utf-8') | ||||||
|         return json.dumps(result).encode('UTF-8') |         return json.dumps(result).encode('UTF-8') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @ -52,8 +52,8 @@ class Storage(object): | ||||||
|         return metadata.tables.get((schema and schema + '.' or '') + tableName) |         return metadata.tables.get((schema and schema + '.' or '') + tableName) | ||||||
| 
 | 
 | ||||||
|     def dropTable(self, tableName): |     def dropTable(self, tableName): | ||||||
|         prefix = self.schema and self.schema + '.' or '' |  | ||||||
|         with self.engine.begin() as conn: |         with self.engine.begin() as conn: | ||||||
|  |             prefix = self.schema and self.schema + '.' or '' | ||||||
|             conn.execute(text('drop table if exists %s%s' % (prefix, tableName))) |             conn.execute(text('drop table if exists %s%s' % (prefix, tableName))) | ||||||
| 
 | 
 | ||||||
|     def resetSequence(self, tableName, colName, v): |     def resetSequence(self, tableName, colName, v): | ||||||
|  | @ -63,19 +63,15 @@ class Storage(object): | ||||||
|             conn.execute(text(sq)) |             conn.execute(text(sq)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class StorageFactory: | class StorageFactory(object): | ||||||
| 
 | 
 | ||||||
|     def sessionFactory(self): |     def sessionFactory(self): | ||||||
|          return self.engine.connect |          return self.engine.connect | ||||||
| 
 | 
 | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def getEngine(dbtype, dbname, user, pw, **kw): |     def getEngine(dbtype, dbname, user, pw, host='localhost', port=5432, **kw): | ||||||
|         return create_engine('%s:///%s' % (dbtype, dbname), **kw) |         return create_engine('%s:///%s' % (dbtype, dbname), **kw) | ||||||
| 
 | 
 | ||||||
|     def engineFromConfig(self, config): |  | ||||||
|         return self.getEngine(config.dbengine, config.dbname,  |  | ||||||
|                               config.dbuser, config.dbpassword)  |  | ||||||
| 
 |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def mark_changed(session): |     def mark_changed(session): | ||||||
|         pass |         pass | ||||||
|  | @ -90,7 +86,8 @@ class StorageFactory: | ||||||
|     storageClass = Storage |     storageClass = Storage | ||||||
| 
 | 
 | ||||||
|     def __init__(self, config, storageClass=None): |     def __init__(self, config, storageClass=None): | ||||||
|         self.engine = self.engineFromConfig(config) |         self.engine = self.getEngine(config.dbengine, config.dbname,  | ||||||
|  |                                      config.dbuser, config.dbpassword)  | ||||||
|         self.Session = self.sessionFactory() |         self.Session = self.sessionFactory() | ||||||
|         if storageClass is not None: |         if storageClass is not None: | ||||||
|             self.storageClass = storageClass |             self.storageClass = storageClass | ||||||
|  |  | ||||||
|  | @ -24,12 +24,6 @@ class StorageFactory(StorageFactory): | ||||||
|         return create_engine('%s://%s:%s@%s:%s/%s' % ( |         return create_engine('%s://%s:%s@%s:%s/%s' % ( | ||||||
|             dbtype, user, pw, host, port, dbname), **kw) |             dbtype, user, pw, host, port, dbname), **kw) | ||||||
| 
 | 
 | ||||||
|     def engineFromConfig(self, config): |  | ||||||
|         return self.getEngine(config.dbengine, config.dbname,  |  | ||||||
|                               config.dbuser, config.dbpassword, |  | ||||||
|                               host=getattr(config, 'dbhost', 'localhost'), |  | ||||||
|                               port=getattr(config, 'dbport', 5432)) |  | ||||||
| 
 |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def mark_changed(session): |     def mark_changed(session): | ||||||
|         return mark_changed(session) |         return mark_changed(session) | ||||||
|  |  | ||||||
|  | @ -7,18 +7,6 @@ from scopes.storage.common import registerContainerClass | ||||||
| from scopes.storage.tracking import Container, Track | from scopes.storage.tracking import Container, Track | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class DummyFolder(dict): |  | ||||||
| 
 |  | ||||||
|     prefix = 'dummy' |  | ||||||
| 
 |  | ||||||
|     def asDict(self): |  | ||||||
|         return self |  | ||||||
| 
 |  | ||||||
|     def __repr__(self): |  | ||||||
|         return '<%s: %s>' % (self.__class__.__name__,  |  | ||||||
|                              super(DummyFolder, self).__repr__()) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @implementer(IContainer, IReference) | @implementer(IContainer, IReference) | ||||||
| class Folder(Track): | class Folder(Track): | ||||||
| 
 | 
 | ||||||
|  | @ -69,8 +57,6 @@ class Root(Folder): | ||||||
|     """A dummy (virtual) root folder for creating real folders |     """A dummy (virtual) root folder for creating real folders | ||||||
|        using the Folder API.""" |        using the Folder API.""" | ||||||
| 
 | 
 | ||||||
|     prefix = 'root' |  | ||||||
| 
 |  | ||||||
|     def __init__(self, storage): |     def __init__(self, storage): | ||||||
|         cont = storage.create(Folders) |         cont = storage.create(Folders) | ||||||
|         super(Root, self).__init__(container=cont) |         super(Root, self).__init__(container=cont) | ||||||
|  |  | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| # scopes.storage.message |  | ||||||
| 
 |  | ||||||
| """Generic messages (or events) to be stored in SQL database.""" |  | ||||||
| 
 |  | ||||||
| from scopes.storage.common import registerContainerClass |  | ||||||
| from scopes.storage.tracking import Container, Track |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Message(Track): |  | ||||||
| 
 |  | ||||||
|     headFields = ['domain', 'action', 'class', 'item'] |  | ||||||
|     prefix = 'msg' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @registerContainerClass |  | ||||||
| class Messages(Container): |  | ||||||
| 
 |  | ||||||
|     itemFactory = Message |  | ||||||
|     indexes = [('domain', 'action', 'class', 'item'), ('domain', 'class', 'item')] |  | ||||||
|     tableName = 'messages' |  | ||||||
|     insertOnChange = True |  | ||||||
|  | @ -15,13 +15,12 @@ from sqlalchemy import and_ | ||||||
| from scopes.storage.common import registerContainerClass | from scopes.storage.common import registerContainerClass | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Track: | class Track(object): | ||||||
|      |      | ||||||
|     headFields = ['taskId', 'userName'] |     headFields = ['taskId', 'userName'] | ||||||
|     prefix = 'rec' |     prefix = 'rec' | ||||||
| 
 | 
 | ||||||
|     def __init__(self, *keys, data=None, timeStamp=None, trackId=None,  |     def __init__(self, *keys, **kw): | ||||||
|                  container=None, **kw): |  | ||||||
|         self.head = {} |         self.head = {} | ||||||
|         for k, v in kw.items(): |         for k, v in kw.items(): | ||||||
|             if k in self.headFields: |             if k in self.headFields: | ||||||
|  | @ -32,10 +31,10 @@ class Track: | ||||||
|             if self.head.get(k) is None: |             if self.head.get(k) is None: | ||||||
|                 self.head[k] = '' |                 self.head[k] = '' | ||||||
|             setattr(self, k, self.head[k]) |             setattr(self, k, self.head[k]) | ||||||
|         self.data = data or {} |         self.data = kw.get('data') or {} | ||||||
|         self.timeStamp = timeStamp |         self.timeStamp = kw.get('timeStamp') | ||||||
|         self.trackId = trackId |         self.trackId = kw.get('trackId') | ||||||
|         self.container = container |         self.container = kw.get('container') | ||||||
| 
 | 
 | ||||||
|     def set(self, attr, value): |     def set(self, attr, value): | ||||||
|         if attr in self.headFields: |         if attr in self.headFields: | ||||||
|  | @ -67,7 +66,7 @@ class Track: | ||||||
|         return str(self.trackId) |         return str(self.trackId) | ||||||
| 
 | 
 | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return '<%s: %s>' % (self.__class__.__name__, self.asDict()) |         return '%s: %s' % (self.__class__.__name__, self.asDict()) | ||||||
| 
 | 
 | ||||||
|     def asDict(self): |     def asDict(self): | ||||||
|         return dict(uid=self.uid, head=self.head, data=self.data,  |         return dict(uid=self.uid, head=self.head, data=self.data,  | ||||||
|  | @ -117,8 +116,7 @@ class Container(object): | ||||||
|                 and_(*self.setupWhere(crit))).order_by(self.table.c.trackid) |                 and_(*self.setupWhere(crit))).order_by(self.table.c.trackid) | ||||||
|         else: |         else: | ||||||
|             stmt = self.table.select().order_by(self.table.c.trackid) |             stmt = self.table.select().order_by(self.table.c.trackid) | ||||||
|         #return [self.makeTrack(r) for r in self.session.execute(stmt)] |         for r in self.session.execute(stmt): | ||||||
|         for r in self.session.execute(stmt).all(): |  | ||||||
|             yield self.makeTrack(r) |             yield self.makeTrack(r) | ||||||
| 
 | 
 | ||||||
|     def queryLast(self, **crit): |     def queryLast(self, **crit): | ||||||
|  |  | ||||||
							
								
								
									
										33
									
								
								scopes/tests.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								scopes/tests.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | ||||||
|  | # scopes/tests.py | ||||||
|  | 
 | ||||||
|  | """The real test implementations""" | ||||||
|  | 
 | ||||||
|  | import unittest | ||||||
|  | from scopes import tlib_storage | ||||||
|  | 
 | ||||||
|  | import config | ||||||
|  | config.dbengine = 'postgresql' | ||||||
|  | config.dbname = 'ccotest' | ||||||
|  | config.dbuser = 'ccotest' | ||||||
|  | config.dbpassword = 'cco' | ||||||
|  | config.dbschema = 'testing' | ||||||
|  | 
 | ||||||
|  | # PostgreSQL-specific settings | ||||||
|  | from scopes.storage.db.postgres import StorageFactory  | ||||||
|  | config.storageFactory = StorageFactory(config) | ||||||
|  | #storage = factory(schema='testing') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Test(unittest.TestCase): | ||||||
|  | 
 | ||||||
|  |     def test_001_tracking(self): | ||||||
|  |         tlib_storage.test_tracking(self, config) | ||||||
|  |     | ||||||
|  |     def test_002_folder(self): | ||||||
|  |         tlib_storage.test_folder(self, config) | ||||||
|  |     | ||||||
|  |     def test_003_type(self): | ||||||
|  |         tlib_storage.test_type(self, config) | ||||||
|  |     | ||||||
|  |     def test_004_topic(self): | ||||||
|  |         tlib_storage.test_topic(self, config) | ||||||
|  | @ -1 +0,0 @@ | ||||||
| # py-scopes/tests |  | ||||||
|  | @ -1,52 +0,0 @@ | ||||||
| # py-scopes/tests/config.py |  | ||||||
| 
 |  | ||||||
| import logging |  | ||||||
| from os import getenv |  | ||||||
| import sys |  | ||||||
| 
 |  | ||||||
| #from scopes.web.app import demo_app, zope_app |  | ||||||
| 
 |  | ||||||
| log_file = 'scopes/tests/log/scopes-test.log' |  | ||||||
| log_level = logging.INFO |  | ||||||
| log_format = '%(asctime)s %(levelname)s %(name)s %(message)s' |  | ||||||
| log_dateformat = '%Y-%m-%dT%H:%M:%S' |  | ||||||
| 
 |  | ||||||
| # server / app settings |  | ||||||
| server_port = '8999' |  | ||||||
| base_url = 'testing:' |  | ||||||
| #app = zope_app |  | ||||||
| 
 |  | ||||||
| # storage settings |  | ||||||
| 
 |  | ||||||
| # SQLite |  | ||||||
| dbengine = 'sqlite' |  | ||||||
| dbname = 'var/test.db' |  | ||||||
| dbuser = None |  | ||||||
| dbpassword = None |  | ||||||
| dbschema = None |  | ||||||
| 
 |  | ||||||
| # special testing stuff |  | ||||||
| from scopes.tests import data_auth # add oidc URIs and keys to dummy_requests data |  | ||||||
| from scopes.tests import dummy_requests |  | ||||||
| sys.modules['requests'] = dummy_requests |  | ||||||
| 
 |  | ||||||
| # authentication settings |  | ||||||
| oidc_provider = 'test://oidc' |  | ||||||
| oidc_client_id = getenv('OIDC_CLIENT_ID', '12345') |  | ||||||
| oidc_params = dict( |  | ||||||
|     op_config_url=oidc_provider + '/.well-known/openid-configuration', |  | ||||||
|     op_uris=None, |  | ||||||
|     op_keys=None, |  | ||||||
|     op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud', |  | ||||||
|     callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'), |  | ||||||
|     client_id=oidc_client_id, |  | ||||||
|     principal_prefix=getenv('OIDC_PRINCIPAL_PREFIX', 'loops.'), |  | ||||||
|     cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id), |  | ||||||
|     cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None), |  | ||||||
|     cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'), |  | ||||||
|     cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None), |  | ||||||
|     private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE',  |  | ||||||
|                             'scopes/tests/test-private-key.json'), |  | ||||||
|     organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'), |  | ||||||
|     project_id=getenv('OIDC_PROJECT_ID', None), |  | ||||||
| ) |  | ||||||
|  | @ -1,37 +0,0 @@ | ||||||
| # scopes.tests.data_auth |  | ||||||
| 
 |  | ||||||
| """provide response data for testing (via dummy_requests)""" |  | ||||||
| 
 |  | ||||||
| from cryptography.hazmat.primitives.asymmetric import rsa |  | ||||||
| from scopes import util |  | ||||||
| 
 |  | ||||||
| private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) |  | ||||||
| public_key = private_key.public_key() |  | ||||||
| public_key_n = util.b64e(public_key.public_numbers().n.to_bytes(256)).decode('ASCII') |  | ||||||
| 
 |  | ||||||
| oidc_data = { |  | ||||||
|     'test://oidc/.well-known/openid-configuration': { |  | ||||||
|         "issuer": "test://oidc", |  | ||||||
|         "authorization_endpoint": "test://oidc/oauth/v2/authorize", |  | ||||||
|         "token_endpoint": "test://oidc/oauth/v2/token", |  | ||||||
|         "introspection_endpoint": "test://oidc/oauth/v2/introspect", |  | ||||||
|         "userinfo_endpoint": "test://oidc/oidc/v1/userinfo", |  | ||||||
|         "revocation_endpoint": "test://oidc/oauth/v2/revoke", |  | ||||||
|         "end_session_endpoint": "test://oidc/oidc/v1/end_session", |  | ||||||
|         "device_authorization_endpoint": "test://oidc/oauth/v2/device_authorization", |  | ||||||
|         "jwks_uri": "test://oidc/oauth/v2/keys"}, |  | ||||||
|     'test://oidc/oauth/v2/keys': { "keys": [ |  | ||||||
|        {"use": "sig", |  | ||||||
|         "kty": "RSA", |  | ||||||
|         "kid": "316766976250797901", |  | ||||||
|         "alg": "RS256", |  | ||||||
|         "n": public_key_n, |  | ||||||
|         "e": "AQAB"}]}, |  | ||||||
|     'test://oidc/oauth/v2/token': { |  | ||||||
|         "access_token": "abcde12345"}, |  | ||||||
|     'test://oidc/v2/users/human': { |  | ||||||
|         "code": 1} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| from scopes.tests.dummy_requests import response_data |  | ||||||
| response_data.update(oidc_data) |  | ||||||
|  | @ -1,30 +0,0 @@ | ||||||
| # scopes.tests.requests |  | ||||||
| 
 |  | ||||||
| """Dummy requests implementation for testing.""" |  | ||||||
| 
 |  | ||||||
| from logging import getLogger |  | ||||||
| logger = getLogger('tests.dummy_requests') |  | ||||||
| 
 |  | ||||||
| def get(url, *args, **kw): |  | ||||||
|     logger.info(f'get: %s - %s - %s', url, args, kw) |  | ||||||
|     return FakeResponse(response_data[url]) |  | ||||||
| 
 |  | ||||||
| def post(url, *args, **kw): |  | ||||||
|     logger.info(f'post: %s - %s - %s', url, args, kw) |  | ||||||
|     return FakeResponse(response_data[url]) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class FakeResponse: |  | ||||||
| 
 |  | ||||||
|     def __init__(self, data): |  | ||||||
|         self.data = data |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def status_code(self): |  | ||||||
|         return 200 |  | ||||||
| 
 |  | ||||||
|     def json(self): |  | ||||||
|         return self.data |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| response_data = {} |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| directory for logfiles created by tests |  | ||||||
|  | @ -1,36 +0,0 @@ | ||||||
| # scopes.tests.scratch |  | ||||||
| 
 |  | ||||||
| """for keeping experimental and demonstration code and data (mainly for testing)""" |  | ||||||
| 
 |  | ||||||
| import base64 |  | ||||||
| from cryptography.hazmat.primitives.asymmetric import rsa |  | ||||||
| from cryptography.hazmat.primitives import serialization |  | ||||||
| from cryptography.utils import int_to_bytes |  | ||||||
| from scopes import util |  | ||||||
| 
 |  | ||||||
| # generate (and serialize) or load private key |  | ||||||
| # generate public JWKS (key set) from private ky |  | ||||||
| 
 |  | ||||||
| #private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) |  | ||||||
| #pem = private_key.private_bytes(encoding=serialization.Encoding.PEM,  |  | ||||||
| #                                format=serialization.PrivateFormat.PKCS8, |  | ||||||
| #                                encryption_algorithm=serialization.NoEncryption()) |  | ||||||
| 
 |  | ||||||
| pem = b'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD1ipW1hnV+OWF/\nE09YEQB9GTwQ0DUhmM1ZtmONV3xp1zrpLsvQtJJ6lZ8rp+ws35HGEEQWuBvgKP+K\nyyhILkgHp5LUtXsIruvo/+20Gbxr3bbIp2Omv8NuUnQIlb0SkZwauz/iLi+dFnoW\nTzkCwRnOPG+xqrSzD4CUCIZc4fU61h457I5PWTgK+bZXlsPytook9HpCIPX+9PaU\n935KHeWLonx3GZWqcqUyHSUn8dgRMypaKO70zCZkZWwDbCkJv7OUuY9QijN9niA+\nX3MFyqf9+WK7/lLnNYimdXk9Pz9Lim7HhoAApoW/0ueUfok54OIzuemTAUaunaN6\nmdOvh54dAgMBAAECggEAAWW5Vx7kAY1Bto104U51EEm519EvhUm6B5T4+VTp5LYK\nF1rEMIFwtBkoFfjzLJFEwEakJ8Hgq80TIRdyJ72r9ACIPGtTlH0Jjg94IqyvwOy6\n23fPEXkrLHR0Z5g8bR+6A95liQrA+tCD1QVFRAWUktlkVXfxRC95TMCA3i7rakGG\no6BbyUD2Hp7IEO5fbfKNiqrUO/LwvLOMBTe3Q9EVv7fblm21HiwWG26RgO29UtFo\nEKcaALSqIYwYCkc9EuLpcGRzLfH1opid3Xtu4+hm5enNgdXZRKr9lSQUCSi9oZRE\npszNGyDsaot2ixwKP3c6Eo3bQKMmXXw8Y3JsNiZzIQKBgQD8QuCjKyAeH0QrhW1Y\nvSmUC3q517WZPRgsEMx917MYn/Gl1ywsROjawP/ClxTZkpXVgALFvrggjB75p/QG\noFamZpf9U95fVivAkANQYmhGmqPVQUeb2vgHQOdPSdvvMoRumcEcfS/dOo9R//s+\nfrBQNYfp/tTttdyM2N0CzsuBzQKBgQD5LjX8SvLPe6q6xJchdExywbTk+DLtiKDs\nT5avaGhOqzYhir6FvEt7Gw74J6IWzIt5X4OteSkpRvj2TN0ik+KAfZOk+v9C5iK7\nxbicWj5XSaPJcYvhxogO7mb8sSrl/cY20HRkCsyQOKeH005PHsGZztZoHrsF6t2X\nbtKgNWp9kQKBgHLDHh09RlxNzx6Zkfh3/k1qt4eKmgQ/5hpN/ioWElVWloHjFSaC\npwi2GuT1BLhC1sWNejVqIaw08vaTMRI+qY0ESYsnN5hZxIfTPJ66VkQgn/4pt6Ex\nCfuKzHCm4la8vcDvVApY7YiQ1pjwguWYjy++WrnahBYs0UyGcG2RlMXVAoGBAJNJ\nf1ubqZ5+yNIQ9gwuRCno2dYl52SESCqmeLlCC7XEegCllCxUuoEP429HbgXv7dlW\nXe0iGvRtISflEyknJNEyaR0xx8RxZ8J6Ar9YkFTkEE44MajIww+gV3ux9Vtw/8LS\nwJmJ0JTHCC++9SDLW0BhBFcTIxVCWKz0MsfECyghAoGAOLm2dF59JiiiynGiWXzJ\nZcbZQStR+ysjLX+RqTATAorhweUiv+HwbB4boWhOnDD7q3t+93GnzeqI9m9Q5yo6\nRTyyNYa4uJXMMarZxi3m+RGvuCC2h+7ja6H6UGg3xrHNwcykJkuRAZvqCN+f6Nxo\njc8o9LMimQyTv+yptrOhi2w=\n-----END PRIVATE KEY-----\n' |  | ||||||
| private_key = serialization.load_pem_private_key(pem, password=None) |  | ||||||
| public_key = private_key.public_key() |  | ||||||
| 
 |  | ||||||
| nums = public_key.public_numbers() |  | ||||||
| #e = base64.urlsafe_b64encode(int_to_bytes(nums.e)).rstrip(b'=') |  | ||||||
| #e = base64.urlsafe_b64encode(nums.e.to_bytes(3)).rstrip(b'=') |  | ||||||
| e = b'AQAB' |  | ||||||
| #n = base64.urlsafe_b64encode(int_to_bytes(nums.n)).rstrip(b'=') |  | ||||||
| n = util.b64e(nums.n.to_bytes(256)) |  | ||||||
| 
 |  | ||||||
| # data from example session with zitadel |  | ||||||
| 
 |  | ||||||
| keys_response = {"keys":[{"use":"sig","kty":"RSA","kid":"317781576895225677","alg":"RS256","n":"rBE2kf4S1-uLqTKoRcxmBDZXeNU0PcDZtXX3pYK4hbE_cVNqMnWZQNXh4asLvZ_VpInIRI_gRe5pFfrUfnFppnu7sgUGPHS_i6E1JSDWDDViwU0DbQXzTMA36xb8fI9vm_StrKqQ2snpkV2YprWC597vOa6w8eWMEfwWV39gmpgjyaYfwKi2ldvnn_djYSEyy6Cs8QW9L8uaTKDYUuuIY7iyjTRI7kM1boZcg26WQ4nDgSU8BMEmPGc-h7BHIm3PVEKNBUU3dRIcvIlnAx7AJoqdmUU0BllMGOd-To7PP760Vx4gYBsTFRGaQ_9P7vRApn0Wywnhj0BaVWGyJPkggQ","e":"AQAB"}]} |  | ||||||
| 
 |  | ||||||
| token_response = {'access_token': 'WGn4xjOluOgEugwqfxACziBZxAxQ1SKBKU6p27DysWw9slM55Fn7wkz_avpy5BjFR1uU_2rJoQTMNsO8NyxOEkmhbpP9xURUQrLRBiZT', 'token_type': 'Bearer', 'expires_in': 43199, 'id_token': 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjMxNzc4MTU3Njg5NTIyNTY3NyIsInR5cCI6IkpXVCJ9.eyJhbXIiOlsicHdkIl0sImF0X2hhc2giOiJQbC1kMjBvRG9DRHZqTUtGMmF4S3N3IiwiYXVkIjpbIjMxMTYxMzExOTgxNjM5MjUyNSIsIjMxNDQ2OTIwOTA3OTkzMDcwMSIsIjMxMTQ3MjQ1MTc2ODg2ODY4NSJdLCJhdXRoX3RpbWUiOjE3NDU5MTAxNTksImF6cCI6IjMxMTYxMzExOTgxNjM5MjUyNSIsImNsaWVudF9pZCI6IjMxMTYxMzExOTgxNjM5MjUyNSIsImVtYWlsIjoiaGVsbXV0Lm1lcnpAcG9zdGVvLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTc0NTk1MzUwOSwiZmFtaWx5X25hbWUiOiJVc2VyIiwiZ2VuZGVyIjoibWFsZSIsImdpdmVuX25hbWUiOiJUZXN0IiwiaWF0IjoxNzQ1OTEwMzA5LCJpc3MiOiJodHRwczovL2ExLmN5Ny5kZSIsImxvY2FsZSI6ImRlIiwibmFtZSI6IlRlc3QgVXNlciIsIm5vbmNlIjoiMVJGem9RNDkyazNWTFEzcyIsInByZWZlcnJlZF91c2VybmFtZSI6InRzdDkiLCJzaWQiOiJWMV8zMTc3ODQyMjY2MjE2MTE4NTMiLCJzdWIiOiIzMTE4NDY3OTY3Mzk1MzQ2NjkiLCJ1cGRhdGVkX2F0IjoxNzQ1MzA1MTkzLCJ1cm46eml0YWRlbDppYW06b3JnOnByb2plY3Q6MzExNDcyNDUxNzY4ODY4Njg1OnJvbGVzIjp7ImNjLXVzZXIiOnsiMzExNDczNTAyMjc0MjQ4NTI1IjoiY3liZXJjb25jZXB0cy5hMS5jeTcuZGUifX0sInVybjp6aXRhZGVsOmlhbTpvcmc6cHJvamVjdDpyb2xlcyI6eyJjYy11c2VyIjp7IjMxMTQ3MzUwMjI3NDI0ODUyNSI6ImN5YmVyY29uY2VwdHMuYTEuY3k3LmRlIn19LCJ1cm46eml0YWRlbDppYW06dXNlcjpyZXNvdXJjZW93bmVyOmlkIjoiMzExNDczNTAyMjc0MjQ4NTI1IiwidXJuOnppdGFkZWw6aWFtOnVzZXI6cmVzb3VyY2Vvd25lcjpuYW1lIjoiY3liZXJjb25jZXB0cyIsInVybjp6aXRhZGVsOmlhbTp1c2VyOnJlc291cmNlb3duZXI6cHJpbWFyeV9kb21haW4iOiJjeWJlcmNvbmNlcHRzLmExLmN5Ny5kZSJ9.hx8JWXWVPq-WfBx9AMgFRWaEX3CioU8BCZwT3d8YETMejsR33LDsbPfnzO0Dbw885tiyNIsdNRj7lfk5HtuZVWdHx58hnaMw-czGhIfB0GXvuh2UFemBoLNJLajtRvCBi-YIZ_c6TD0Ryd2wn_yQlgY1J2R8xTsBWeKM_SYqlKU20r4r3nsX4krg6Q9x-Kc3k0Rg3lMXoxFYgQSzXrKjxTI3HHb03H4lzgmrdcbQqfbLoHKdvGxzfo-Gf8Rlt9rQ-fpGu7fRfPJQV14SPJ6CQtKP0adq_D2blcp1I1xKXML8TytJa05RjL_l_KH-segFfORtGMc4mYo8JUpMW0KOvg'} |  | ||||||
| 
 |  | ||||||
| token_id_claims = {'amr': ['pwd'], 'at_hash': 'Pl-d20oDoCDvjMKF2axKsw', 'aud': ['311613119816392525', '314469209079930701', '311472451768868685'], 'auth_time': 1745910159, 'azp': '311613119816392525', 'client_id': '311613119816392525', 'email': 'helmut.merz@posteo.de', 'email_verified': True, 'exp': 1745953509, 'family_name': 'User', 'gender': 'male', 'given_name': 'Test', 'iat': 1745910309, 'iss': 'https://a1.cy7.de', 'locale': 'de', 'name': 'Test User', 'nonce': '1RFzoQ492k3VLQ3s', 'preferred_username': 'tst9', 'sid': 'V1_317784226621611853', 'sub': '311846796739534669', 'updated_at': 1745305193, 'urn:zitadel:iam:org:project:311472451768868685:roles': {'cc-user': {'311473502274248525': 'cyberconcepts.a1.cy7.de'}}, 'urn:zitadel:iam:org:project:roles': {'cc-user': {'311473502274248525': 'cyberconcepts.a1.cy7.de'}}, 'urn:zitadel:iam:user:resourceowner:id': '311473502274248525', 'urn:zitadel:iam:user:resourceowner:name': 'cyberconcepts', 'urn:zitadel:iam:user:resourceowner:primary_domain': 'cyberconcepts.a1.cy7.de'} |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| {"type":"serviceaccount","keyId":"314794985486606157","key":"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0dC8wcwu6Uefxx/shqsSTk//ATseeCy28RMAEa4NFGj/y8Ju\nOfVUj7pB5+6onjmsBAHXhCJ+fEWWAJdHnbvywrBNNhVx38f8v+90zUP2IzlT1UDp\ncTIYaehnf3+uqwgMcijnYJ6UgaHFMSecxnYD4adnw8J/FEMDgy2N+v5krp989VQ5\nT2kgrkb/l5z8dgLhmmcLKm7YCG1uXXP+g+qzEZ9Uhur5b+czjIalzC/tq2V2JoJB\nooH9w1iaRXRKel7FZPo0YGyQh/0a9Zn5JsXVc3YTHTKh9madr/yQqmk+6siTl/Ou\ntz9mvpY+AfFRaIWikoyB3W9rHd0b6WtQPflEPwIDAQABAoIBAAN64daZC2IlJPpJ\nhkPJjJkt7H3ZvCykGTiwZvzkFSV0hGGdzPQ7JHbp0PQG2lcdf8PlP+zaIZzwDofd\n+nscRe+CuxUdj/D1QTTxxM8uxGNbLQ/JbtXIzezbxPOxa3U8wfAWy5enqbDovPuO\nu6PzCydv/mGZ1T/ByMohNEyocYUP6mupHWwf2hN/lnrL264w8uvNjAw0xDtbtBJN\nX61u6vi/fiY37qKblN3irAePwK4LIhHZZoyJ1HrFYIkFf0Bviuzpw/ASVqbjizPV\nmTxGxghiQacAMvSSe+pcfJ7ip74rCFv7+6pzL+yW8df1lbSM9vS+86SDgY9RCc2E\n3h1/hUECgYEA/WqiWNXey25qCNB6WHo3SU5cZIZVNWzsT1zkwkXOUtEyU0/zEfT+\nEjW/vbxIBgZNV1tX2aXd7Ke5OCoQ1dqLnmDoO5d13xTeaWN3FR8ibTwbaDCwyg5d\njyIXK2k7IwtcpJFgJFGM/6udAdO/bPm1IPEslJXHBqZoGrKb+bTw6N8CgYEA0/RQ\nHtQluQYBtXNzEql0MaxBUxfHkwjL6Yo6dM+EJAomI+cccVy22s+z2aQX5GVQnbzs\nm9BGkJzzn7eGPy3i2LgStqUZ2W7VqfIJNCIDbC7OxBAaszh5/LEgv5pfp1Yr/HIf\nwHZz53rdV8H+oUfMJdlyrRyGOeGIDZCd94nTMKECgYAQOpT9BW1IL+EAgYFkSydh\nPXBzS5sHWdtkVbmcq2XELfuAFF2np73hoqmN2BHwuNSZJJNir9mffzpAW4lKeL16\nPhCBSHjW+Xoo26LTqnPE9RV4Pa4EspjRQsijEhEkdGTRcTHsAYD7Gp1qcYoPy4oK\n+wb02Qau6Vc/ZnLQsgK/lwKBgQDMLSGxUPQ11E95GAnWBF7mKuWSwemC/opQItRF\nClJk1VIAa/W+Tm3nQwYhti0920tZaFEVmAEh9c/KH+S2n+FSm5+LSmgoSNiSqZGs\nIsfhQwXzYQAXfWQlxAukB3X1oNEmkll78Z+dcYIfs8UyYBOMsngBwuSahWOmjZVe\ni+phgQKBgC0ozpbIcNg48M4/Rrev3qJB7XlU74MySsFJdBhlrzmK3+z02bXWbyaJ\nzQLwC6Dorw0PcWAKtcJcbBn6ZAoptcmG6wdQrYk1IC+82TDcNvAFL06y8OXHYLtu\ni5AiE4nK1waoDF/1I66VACyKI6hhISRW3bKaxHhrx5OsGKVurF4R\n-----END RSA PRIVATE KEY-----\n","expirationDate":"9999-12-31T23:59:59Z","userId":"311889729668833101"} |  | ||||||
|  | @ -1,47 +0,0 @@ | ||||||
| # scopes.tests.test_postgres |  | ||||||
| 
 |  | ||||||
| """Tests for the 'scopes.storage' package - using PostgreSQL.""" |  | ||||||
| 
 |  | ||||||
| import os, sys |  | ||||||
| sys.path = [os.path.dirname(__file__)] + sys.path |  | ||||||
| 
 |  | ||||||
| import unittest |  | ||||||
| from scopes.tests import tlib_storage |  | ||||||
| 
 |  | ||||||
| from scopes.tests.util import setup_logging |  | ||||||
| from scopes.storage.db.postgres import StorageFactory  |  | ||||||
| import config |  | ||||||
| setup_logging(config) |  | ||||||
| 
 |  | ||||||
| config.dbengine = 'postgresql+psycopg' |  | ||||||
| config.dbname = 'testdb' |  | ||||||
| config.dbuser = 'testuser' |  | ||||||
| config.dbpassword = 'secret' |  | ||||||
| config.dbschema = 'testing' |  | ||||||
| config.storageFactory = StorageFactory(config) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Test(unittest.TestCase): |  | ||||||
| 
 |  | ||||||
|     def test_001_tracking(self): |  | ||||||
|         tlib_storage.test_tracking(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_002_folder(self): |  | ||||||
|         tlib_storage.test_folder(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_003_type(self): |  | ||||||
|         tlib_storage.test_type(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_004_topic(self): |  | ||||||
|         tlib_storage.test_topic(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_005_message(self): |  | ||||||
|         tlib_storage.test_message(self, config) |  | ||||||
| 
 |  | ||||||
| def test_suite(): |  | ||||||
|     return unittest.TestSuite(( |  | ||||||
|         unittest.TestLoader().loadTestsFromTestCase(Test), |  | ||||||
|     )) |  | ||||||
| 
 |  | ||||||
| if __name__ == '__main__': |  | ||||||
|     unittest.main(defaultTest='test_suite') |  | ||||||
|  | @ -1,48 +0,0 @@ | ||||||
| # scopes.tests.test_standard |  | ||||||
| 
 |  | ||||||
| """Tests for the 'scopes.storage' package.""" |  | ||||||
| 
 |  | ||||||
| import os, sys |  | ||||||
| sys.path = [os.path.dirname(__file__)] + sys.path |  | ||||||
| 
 |  | ||||||
| import unittest |  | ||||||
| from scopes.tests import tlib_web, tlib_storage |  | ||||||
| 
 |  | ||||||
| from scopes.tests.util import setup_logging |  | ||||||
| from scopes.storage.common import StorageFactory |  | ||||||
| import config |  | ||||||
| setup_logging(config) |  | ||||||
| 
 |  | ||||||
| config.dbengine = 'sqlite' |  | ||||||
| config.dbname = 'var/test.db' |  | ||||||
| config.dbschema = None |  | ||||||
| config.storageFactory = StorageFactory(config) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Test(unittest.TestCase): |  | ||||||
| 
 |  | ||||||
|     def test_001_tracking(self): |  | ||||||
|         tlib_storage.test_tracking(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_002_folder(self): |  | ||||||
|         tlib_storage.test_folder(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_003_type(self): |  | ||||||
|         tlib_storage.test_type(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_004_topic(self): |  | ||||||
|         tlib_storage.test_topic(self, config) |  | ||||||
| 
 |  | ||||||
|     def test_013_web(self): |  | ||||||
|         tlib_web.test_app(self, config) |  | ||||||
|         tlib_web.test_auth(self, config) |  | ||||||
|         tlib_web.test_user_data(self, config) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def suite(): |  | ||||||
|     return unittest.TestSuite(( |  | ||||||
|         unittest.TestLoader().loadTestsFromTestCase(Test), |  | ||||||
|     )) |  | ||||||
| 
 |  | ||||||
| if __name__ == '__main__': |  | ||||||
|     unittest.main(defaultTest='suite') |  | ||||||
|  | @ -1,49 +0,0 @@ | ||||||
| # tests/tlib_web.py |  | ||||||
| 
 |  | ||||||
| """Test implementation for the `scopes.web` package.""" |  | ||||||
| 
 |  | ||||||
| import json |  | ||||||
| import logging |  | ||||||
| from zope.publisher.browser import TestRequest |  | ||||||
| from zope.publisher.publish import publish |  | ||||||
| 
 |  | ||||||
| from scopes.web.app import Publication |  | ||||||
| from scopes.storage.folder import Root |  | ||||||
| 
 |  | ||||||
| logger = logging.getLogger('tlib_web') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def publishRequest(config, storage, path): |  | ||||||
|     appRoot = Root(storage) |  | ||||||
|     request = TestRequest(environ=dict(PATH_INFO=path)) |  | ||||||
|     request.setPublication(Publication(appRoot)) |  | ||||||
|     request = publish(request, False) |  | ||||||
|     return request.response |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_app(self, config): |  | ||||||
|     storage = config.storageFactory(config.dbschema) |  | ||||||
|     response = publishRequest(config, storage, '/top') |  | ||||||
|     logger.info('test_app: response %s %s', response.getStatus(), response.getHeaders()) |  | ||||||
|     result = json.loads(response.consumeBody()) |  | ||||||
|     self.assertEqual(result['items'][0]['head']['name'], 'level2-item1') |  | ||||||
| 
 |  | ||||||
| def test_auth(self, config): |  | ||||||
|     from scopes.web.auth import oidc |  | ||||||
|     oidc.startup()  # todo: use generic app.startServices() |  | ||||||
|     self.assertEqual(len(config.oidc_params['op_uris']), 8) |  | ||||||
|     storage = config.storageFactory(config.dbschema) |  | ||||||
|     response = publishRequest(config, storage, '/top/auth/login') |  | ||||||
|     headers = dict(response.getHeaders()) |  | ||||||
|     logger.info('test_auth: response %s %s', response.getStatus(), headers) |  | ||||||
|     self.assertEqual(response.getStatus(), 302) |  | ||||||
|     uri = config.oidc_params['op_uris']['jwks_uri'] |  | ||||||
|     keys = oidc.loadOidcKeys(uri) |  | ||||||
|     logger.info('test_auth keys: %s', keys) |  | ||||||
| 
 |  | ||||||
| def test_user_data(self, config): |  | ||||||
|     from scopes.org import user |  | ||||||
|     u1 = user.User(login='tst9', email='tst9@example.com', |  | ||||||
|                    firstName='Test', lastName='User') |  | ||||||
|     xu1 = user.ExtUser(u1, idPrefix='test.') |  | ||||||
|     xu1.create(updateIfExists=True) |  | ||||||
|  | @ -1,11 +0,0 @@ | ||||||
| # scopes.tests.util |  | ||||||
| 
 |  | ||||||
| import logging |  | ||||||
| 
 |  | ||||||
| def setup_logging(config): |  | ||||||
|     hdlr = logging.getLogger().handlers[-1] |  | ||||||
|     logging.getLogger().removeHandler(hdlr) # remove NullHandler added by testrunner |  | ||||||
|     logging.basicConfig(filename=config.log_file, level=config.log_level,  |  | ||||||
|                         format=config.log_format, datefmt=config.log_dateformat) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| # tests/tlib_storage.py | # scopes/tlib_storage.py | ||||||
| 
 | 
 | ||||||
| """Test implementation for the `scopes.storage` package.""" | """Test implementation for the `scopes.storage` package.""" | ||||||
| 
 | 
 | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from scopes.storage import concept, folder, message, topic, tracking | from scopes.storage import concept, folder, topic, tracking | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_tracking(self, config): | def test_tracking(self, config): | ||||||
|  | @ -82,7 +82,7 @@ def test_type(self, config): | ||||||
|     concept.setupCoreTypes(storage) |     concept.setupCoreTypes(storage) | ||||||
|     types = storage.getContainer(concept.Type) |     types = storage.getContainer(concept.Type) | ||||||
|     tps = list(types.query()) |     tps = list(types.query()) | ||||||
|     self.assertEqual(len(tps), 7) |     self.assertEqual(len(tps), 6) | ||||||
| 
 | 
 | ||||||
|     tfolder = types.queryLast(name='folder') |     tfolder = types.queryLast(name='folder') | ||||||
|     fldrs = list(tfolder.values()) |     fldrs = list(tfolder.values()) | ||||||
|  | @ -94,8 +94,6 @@ def test_type(self, config): | ||||||
| 
 | 
 | ||||||
| def test_topic(self, config): | def test_topic(self, config): | ||||||
|     storage = config.storageFactory(config.dbschema) |     storage = config.storageFactory(config.dbschema) | ||||||
|     storage.dropTable('rels') |  | ||||||
|     rels = storage.getContainer(concept.Triple) |  | ||||||
|     storage.dropTable('topics') |     storage.dropTable('topics') | ||||||
|     topics = storage.getContainer(topic.Topic) |     topics = storage.getContainer(topic.Topic) | ||||||
|     types = storage.getContainer(concept.Type) |     types = storage.getContainer(concept.Type) | ||||||
|  | @ -114,23 +112,10 @@ def test_topic(self, config): | ||||||
|         title='Programming Languages',  |         title='Programming Languages',  | ||||||
|         description='Programming Languages')) |         description='Programming Languages')) | ||||||
|     topics.save(tp_proglang) |     topics.save(tp_proglang) | ||||||
|     #storage.commit() # avoid "database locked" error with sqlite |  | ||||||
|     tp_itc.addChild(tp_proglang) |     tp_itc.addChild(tp_proglang) | ||||||
| 
 | 
 | ||||||
|     c = list(tp_itc.children()) |     c = list(tp_itc.children()) | ||||||
|     self.assertEqual(c[0].name, 'prog_lang') |     self.assertEqual(c[0].name, 'prog_lang') | ||||||
| 
 | 
 | ||||||
|     storage.commit() |     storage.commit() | ||||||
| 
 |      | ||||||
| 
 |  | ||||||
| def test_message(self, config): |  | ||||||
|     storage = config.storageFactory(config.dbschema) |  | ||||||
|     storage.dropTable('messages') |  | ||||||
|     messages = storage.create(message.Messages) |  | ||||||
|     m01 = message.Message('system', 'data', 'session', 'V1_317784226621611853') |  | ||||||
|     m01.update(dict(userid='tst9')) |  | ||||||
|     mid01 = messages.save(m01) |  | ||||||
| 
 |  | ||||||
|     storage.commit() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  | @ -1,26 +0,0 @@ | ||||||
| # scopes.util |  | ||||||
| 
 |  | ||||||
| import base64 |  | ||||||
| import hashlib |  | ||||||
| from secrets import choice |  | ||||||
| import string |  | ||||||
| 
 |  | ||||||
| # random strings, hashes, encodings |  | ||||||
| # for authentication, encryption, and other purposes |  | ||||||
| 
 |  | ||||||
| BASE = string.ascii_letters + string.digits |  | ||||||
| BASE2 = BASE + '-._~' |  | ||||||
| 
 |  | ||||||
| def rndstr(size=16): |  | ||||||
|     return ''.join([choice(BASE) for _ in range(size)]) |  | ||||||
| 
 |  | ||||||
| def rndstr2(size=64): |  | ||||||
|     return ''.join([choice(BASE2) for _ in range(size)]) |  | ||||||
| 
 |  | ||||||
| def b64e(b): |  | ||||||
|     return base64.urlsafe_b64encode(b).rstrip(b'=') |  | ||||||
| 
 |  | ||||||
| def hashS256(s): |  | ||||||
|     h = hashlib.sha256(s.encode('ascii')).digest() |  | ||||||
|     return b64e(h).decode('ascii') |  | ||||||
| 
 |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| """package scopes.web""" |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| """package scopes.web.auth""" |  | ||||||
|  | @ -1,307 +0,0 @@ | ||||||
| # scopes.web.auth.uidc |  | ||||||
| 
 |  | ||||||
| from cryptography.fernet import Fernet |  | ||||||
| from datetime import datetime, timedelta, timezone |  | ||||||
| from email.utils import formatdate |  | ||||||
| import json |  | ||||||
| import jwt |  | ||||||
| import logging |  | ||||||
| import requests |  | ||||||
| from time import time |  | ||||||
| from urllib.parse import urlencode |  | ||||||
| from zope.authentication.interfaces import IAuthentication, IPrincipal |  | ||||||
| from zope.interface import Attribute, Interface, implementer |  | ||||||
| from zope.publisher.interfaces import Unauthorized |  | ||||||
| from zope.security.interfaces import IGroupAwarePrincipal |  | ||||||
| 
 |  | ||||||
| from scopes.web.browser import DefaultView, register |  | ||||||
| from scopes.storage.folder import DummyFolder, Root |  | ||||||
| from scopes import util |  | ||||||
| 
 |  | ||||||
| import config |  | ||||||
| 
 |  | ||||||
| logger = logging.getLogger('web.auth.oidc') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # OIDC authentication for browser users (principals) |  | ||||||
| 
 |  | ||||||
| @implementer(IAuthentication) |  | ||||||
| class OidcAuthentication: |  | ||||||
| 
 |  | ||||||
|     def __init__(self, baseAuth): |  | ||||||
|         self.baseAuth = baseAuth |  | ||||||
| 
 |  | ||||||
|     def authenticate(self, request): |  | ||||||
|         auth = Authenticator(request) |  | ||||||
|         prc = auth.authenticate() |  | ||||||
|         if prc is None and self.baseAuth is not None: |  | ||||||
|             prc = self.baseAuth.authenticate(request) |  | ||||||
|         if prc is None: |  | ||||||
|             prc = self.unauthenticatedPrincipal() |  | ||||||
|         return prc |  | ||||||
| 
 |  | ||||||
|     def getPrincipal(self, id): |  | ||||||
|         if self.baseAuth is not None: |  | ||||||
|             return self.baseAuth.getPrincipal(id) |  | ||||||
| 
 |  | ||||||
|     def getPrincipals(self, s): |  | ||||||
|         if self.baseAuth is not None: |  | ||||||
|             return self.baseAuth.getPrincipals(s) |  | ||||||
| 
 |  | ||||||
|     def unauthenticatedPrincipal(self): |  | ||||||
|         if self.baseAuth is not None: |  | ||||||
|             return self.baseAuth.unauthenticatedPrincipal() |  | ||||||
| 
 |  | ||||||
|     def unauthorized(self, id, request): |  | ||||||
|         if self.baseAuth is not None: |  | ||||||
|             return self.baseAuth.unauthorized(id, request) |  | ||||||
|         Authenticator(request).login() |  | ||||||
| 
 |  | ||||||
|     def logout(self, request): |  | ||||||
|         Authenticator(request).logout() |  | ||||||
| 
 |  | ||||||
| authentication = OidcAuthentication(None) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class IExternalPrincipal(Interface): |  | ||||||
|     extUserLink = Attribute('Link to OIDC provider for viewing/editing external user') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @implementer(IGroupAwarePrincipal, IExternalPrincipal) |  | ||||||
| class Principal: |  | ||||||
| 
 |  | ||||||
|     def __init__(self, id, data): |  | ||||||
|         self.id = id |  | ||||||
|         self.data = data |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def title(self): |  | ||||||
|         return self.data['name'] |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def groups(self): |  | ||||||
|         return self.data.get('groups', []) |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def extUserLink(self): |  | ||||||
|         return config.oidc_provider + '/ui/console/users/me' |  | ||||||
| 
 |  | ||||||
|     def asDict(self): |  | ||||||
|         data = self.data.copy() |  | ||||||
|         data['id'] = self.id |  | ||||||
|         return data |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Authenticator(DummyFolder): |  | ||||||
| 
 |  | ||||||
|     prefix = 'auth.oidc' |  | ||||||
| 
 |  | ||||||
|     group_prefix = 'gloops.' |  | ||||||
| 
 |  | ||||||
|     def __init__(self, request): |  | ||||||
|         self.request = request |  | ||||||
|         self.params = config.oidc_params |  | ||||||
|         self.setCrypt(self.params.get('cookie_crypt')) |  | ||||||
| 
 |  | ||||||
|     def setCrypt(self, key): |  | ||||||
|         self.cookieCrypt = key and Fernet(key) or None |  | ||||||
| 
 |  | ||||||
|     def authenticate(self): |  | ||||||
|         ''' return  principal or None''' |  | ||||||
|         data = self.loadSession() |  | ||||||
|         logger.debug('authenticate: %s', data) |  | ||||||
|         if data and 'userid' in data: |  | ||||||
|             id = data.pop('userid') |  | ||||||
|             return Principal(id, data) |  | ||||||
|         return None |  | ||||||
| 
 |  | ||||||
|     def login(self): |  | ||||||
|         state = util.rndstr() |  | ||||||
|         nonce = util.rndstr() |  | ||||||
|         codeVerifier = util.rndstr2() |  | ||||||
|         codeChallenge = util.hashS256(codeVerifier) |  | ||||||
|         reqUrl = self.request.form.get('camefrom') or config.base_url |  | ||||||
|         args = dict( |  | ||||||
|                 client_id=self.params['client_id'], |  | ||||||
|                 response_type='code', # 'code id_token token', |  | ||||||
|                 state=state, nonce=nonce, |  | ||||||
|                 code_challenge=codeChallenge, code_challenge_method='S256', |  | ||||||
|                 scope='openid profile email urn:zitadel:iam:user:resourceowner', |  | ||||||
|                 redirect_uri=self.params['callback_url'], |  | ||||||
|         ) |  | ||||||
|         self.storeSession(dict(state=state, nonce=nonce, request_uri=reqUrl, |  | ||||||
|                                code_verifier=codeVerifier)) |  | ||||||
|         authUrl = self.params['op_uris']['authorization_endpoint'] |  | ||||||
|         loginUrl = '?'.join((authUrl, urlencode(args))) |  | ||||||
|         logger.debug('login: URL %s', loginUrl) |  | ||||||
|         self.request.response.redirect(loginUrl, trusted=True) |  | ||||||
| 
 |  | ||||||
|     def callback(self, groupsProvider=None): |  | ||||||
|         req = self.request |  | ||||||
|         logger.debug('callback: %s %s', self, req.form) |  | ||||||
|         sdata = self.loadSession() |  | ||||||
|         reqUrl = sdata.get('request_uri') or config.base_url |  | ||||||
|         code = req.form['code'] |  | ||||||
|         # !check state: req.form['state'] == sdata['state'] |  | ||||||
|         args = dict( |  | ||||||
|                 grant_type='authorization_code', |  | ||||||
|                 code=code, |  | ||||||
|                 redirect_uri=self.params['callback_url'], |  | ||||||
|                 client_id=self.params['client_id'], |  | ||||||
|                 code_verifier=sdata['code_verifier'] |  | ||||||
|         ) |  | ||||||
|         # !set header: 'Content-Type: application/x-www-form-urlencoded' |  | ||||||
|         tokenUrl = self.params['op_uris']['token_endpoint'] |  | ||||||
|         tokenResponse = requests.post(tokenUrl, data=args) |  | ||||||
|         tdata =  tokenResponse.json() |  | ||||||
|         userData = self.getIdTokenData(tdata['id_token']) |  | ||||||
|         userId = userData['sub'] |  | ||||||
|         if not '.' in userId: |  | ||||||
|             userId = (self.params.get('principal_prefix', '') +  |  | ||||||
|                       userData['preferred_username']) |  | ||||||
|         groups = userData.get('urn:zitadel:iam:org:project:roles', {}) |  | ||||||
|         groups = set(self.group_prefix + g for g in groups) |  | ||||||
|         if groupsProvider is not None: |  | ||||||
|             groups = groups.union(groupsProvider(userId)) |  | ||||||
|         ndata = dict( |  | ||||||
|                 userid=userId, |  | ||||||
|                 name=userData['name'], |  | ||||||
|                 email=userData['email'], |  | ||||||
|                 groups=list(groups), |  | ||||||
|                 access_token=tdata['access_token'], |  | ||||||
|                 session_id=userData['sid'], |  | ||||||
|         ) |  | ||||||
|         self.storeSession(ndata) |  | ||||||
|         logger.debug('callback: session data: %s', ndata) |  | ||||||
|         req.response.redirect(reqUrl, trusted=True) |  | ||||||
| 
 |  | ||||||
|     def logout(self): |  | ||||||
|         logoutUrl = self.params['op_uris']['end_session_endpoint'] |  | ||||||
|         args = dict( |  | ||||||
|                 client_id=self.params['client_id'], |  | ||||||
|                 post_logout_redirect_uri=config.base_url, |  | ||||||
|         ) |  | ||||||
|         logoutUrl = '?'.join((logoutUrl, urlencode(args))) |  | ||||||
|         cname = self.params['cookie_name'] |  | ||||||
|         logger.debug('logout, cookie: %s, url: %s', cname, logoutUrl) |  | ||||||
|         self.request.response.expireCookie(cname, path='/') |  | ||||||
|         self.request.response.redirect(logoutUrl, trusted=True) |  | ||||||
| 
 |  | ||||||
|     def storeSession(self, data): |  | ||||||
|         lifetime = int(self.params['cookie_lifetime']) |  | ||||||
|         options = dict( |  | ||||||
|                 path='/', |  | ||||||
|                 expires=formatdate(time() + lifetime, localtime=False, usegmt=True), |  | ||||||
|                 httponly=True, |  | ||||||
|         ) |  | ||||||
|         options['max-age'] = lifetime |  | ||||||
|         domain = self.params['cookie_domain'] |  | ||||||
|         if domain: |  | ||||||
|             options['domain'] = domain |  | ||||||
|         name = self.params['cookie_name'] |  | ||||||
|         value = json.dumps(data) |  | ||||||
|         if self.cookieCrypt: |  | ||||||
|             value = self.cookieCrypt.encrypt(value.encode('UTF-8')).decode('ASCII') |  | ||||||
|         self.request.response.setCookie(name, value, **options) |  | ||||||
| 
 |  | ||||||
|     def loadSession(self): |  | ||||||
|         cookie = self.request.getCookies().get(self.params['cookie_name']) |  | ||||||
|         if cookie is None: |  | ||||||
|             return {} |  | ||||||
|         if self.cookieCrypt: |  | ||||||
|             cookie = self.cookieCrypt.decrypt(cookie) |  | ||||||
|         # !error check: return None - or raise error? |  | ||||||
|         data = json.loads(cookie) |  | ||||||
|         return data |  | ||||||
| 
 |  | ||||||
|     def getIdTokenData(self, token): |  | ||||||
|         uri = self.params['op_uris']['jwks_uri'] |  | ||||||
|         keys = loadOidcKeys(uri) |  | ||||||
|         header = jwt.get_unverified_header(token) |  | ||||||
|         key = jwt.PyJWK(keys[header['kid']]) |  | ||||||
|         return jwt.decode(token, key, audience=self.params['client_id']) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @register('auth') |  | ||||||
| def authView(context, request): |  | ||||||
|     return Authenticator(request) |  | ||||||
| 
 |  | ||||||
| @register('login', Authenticator) |  | ||||||
| def login(context, request): |  | ||||||
|     context.login() |  | ||||||
|     return DefaultView(context, request) |  | ||||||
| 
 |  | ||||||
| @register('callback', Authenticator) |  | ||||||
| def callback(context, request): |  | ||||||
|     context.callback() |  | ||||||
|     return DefaultView(context, request) |  | ||||||
| 
 |  | ||||||
| @register('logout', Authenticator) |  | ||||||
| def logout(context, request): |  | ||||||
|     context.logout() |  | ||||||
|     return DefaultView(context, request) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def startup(): |  | ||||||
|     try: |  | ||||||
|         loadOidcProviderData() |  | ||||||
|     except requests.exceptions.JSONDecodeError as e: |  | ||||||
|         logger.error(f'oidc.loadOidcProviderData: {e} - OIDC provider not available!') |  | ||||||
|     #app.Publication.registerBeforeTraversal( |  | ||||||
|     #       lambda req: req.setPrincipal(authentication.authenticate(req)) |  | ||||||
| 
 |  | ||||||
| oidcProviderUris = ['authorization_endpoint', 'token_endpoint',  |  | ||||||
|                     'introspection_endpoint', 'userinfo_endpoint', |  | ||||||
|                     'revocation_endpoint', 'end_session_endpoint', |  | ||||||
|                     'device_authorization_endpoint', 'jwks_uri'] |  | ||||||
| 
 |  | ||||||
| def loadOidcProviderData(force=False): |  | ||||||
|     params = config.oidc_params |  | ||||||
|     if force or params.get('op_uris') is None: |  | ||||||
|         uris = params['op_uris'] = {} |  | ||||||
|         opData = requests.get(params['op_config_url']).json() |  | ||||||
|         for key in oidcProviderUris: |  | ||||||
|             uris[key] = opData[key] |  | ||||||
|     #if force or params.get('op_keys') is None: |  | ||||||
|         #params['op_keys'] = requests.get(uris['jwks_uri']).json()['keys'] |  | ||||||
| 
 |  | ||||||
| def loadOidcKeys(uri): |  | ||||||
|     return dict((item['kid'], item) for item in requests.get(uri).json()['keys']) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # service user authentication |  | ||||||
| 
 |  | ||||||
| def authenticateClient(paramsName='oidc_params'): |  | ||||||
|     loadOidcProviderData() |  | ||||||
|     params = getattr(config, paramsName) |  | ||||||
|     keyData = loadPrivateKeyData(params['private_key_file']) |  | ||||||
|     userId = keyData['userId'] |  | ||||||
|     keyId = keyData['keyId'] |  | ||||||
|     key = keyData['key'] |  | ||||||
|     now = datetime.now(timezone.utc) |  | ||||||
|     token_lifetime=params.get('api_token_lifetime', 60) |  | ||||||
|     payload = dict( |  | ||||||
|             iss=userId, sub=userId, aud=config.oidc_provider, |  | ||||||
|             iat=now, exp=now + timedelta(minutes=token_lifetime), |  | ||||||
|     ) |  | ||||||
|     jwToken = jwt.encode(payload, key, algorithm="RS256",  |  | ||||||
|                          headers=dict(alg='RS256', kid=keyId)) |  | ||||||
|     data = dict( |  | ||||||
|             grant_type='urn:ietf:params:oauth:grant-type:jwt-bearer', |  | ||||||
|             scope=' '.join(('openid', params['op_project_scope'])), |  | ||||||
|             assertion=jwToken, |  | ||||||
|     ) |  | ||||||
|     headers = {'Content-Type': 'application/x-www-form-urlencoded'} |  | ||||||
|     url = params['op_uris']['token_endpoint'] |  | ||||||
|     resp = requests.post(url, data=data, headers=headers) |  | ||||||
|     if resp.status_code != 200: |  | ||||||
|         #print(resp.text) |  | ||||||
|         logger.error('authenticateClient: %s', resp.text) |  | ||||||
|         return None |  | ||||||
|     tdata = resp.json() |  | ||||||
|     #print(tdata) |  | ||||||
|     return tdata['access_token'] |  | ||||||
| 
 |  | ||||||
| def loadPrivateKeyData(fn='.private-key.json'): |  | ||||||
|     with open(fn) as f: |  | ||||||
|         return json.load(f) |  | ||||||
|  | @ -1,43 +0,0 @@ | ||||||
| # scopes.web.client |  | ||||||
| 
 |  | ||||||
| """Web client functionality: access to web sites, APIs with authentication.""" |  | ||||||
| 
 |  | ||||||
| import logging |  | ||||||
| import requests |  | ||||||
| from scopes.web.auth import oidc |  | ||||||
| 
 |  | ||||||
| logger = logging.getLogger('web.client') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ApiClient: |  | ||||||
| 
 |  | ||||||
|     def __init__(self, baseUrl, authToken=None): |  | ||||||
|         self.baseUrl = baseUrl |  | ||||||
|         self.authToken = authToken |  | ||||||
| 
 |  | ||||||
|     def authentication(self): |  | ||||||
|         if self.authToken == None: |  | ||||||
|             self.authToken = oidc.authenticateClient() |  | ||||||
|         return dict(Authorization=f'Bearer {self.authToken}') |  | ||||||
| 
 |  | ||||||
|     def post(self, endpoint, data): |  | ||||||
|         headers = self.authentication() |  | ||||||
|         headers['Content-Type'] = 'application/json' |  | ||||||
|         headers['Connect-Protocol-Version'] = '1' |  | ||||||
|         # self.makeUrl(endpoint) |  | ||||||
|         url = '/'.join((self.baseUrl, endpoint)) |  | ||||||
|         resp = requests.post(url, json=data, headers=headers) |  | ||||||
|         if resp.status_code >= 400: |  | ||||||
|             logger.error('post %s: %s %s', url, resp.status_code, resp.text) |  | ||||||
|         return resp.status_code, resp.json() |  | ||||||
| 
 |  | ||||||
|     def put(self, endpoint, objId, data): |  | ||||||
|         headers = self.authentication() |  | ||||||
|         headers['Content-Type'] = 'application/json' |  | ||||||
|         # self.makeUrl(endpoint, objId) |  | ||||||
|         url = '/'.join((self.baseUrl, endpoint, objId)) |  | ||||||
|         resp = requests.put(url, json=data, headers=headers) |  | ||||||
|         if resp.status_code >= 400: |  | ||||||
|             logger.error('post %s: %s %s', url, resp.status_code, resp.text) |  | ||||||
|         return resp.status_code, resp.json() |  | ||||||
| 
 |  | ||||||
							
								
								
									
										47
									
								
								setup.py
									
										
									
									
									
								
							
							
						
						
									
										47
									
								
								setup.py
									
										
									
									
									
								
							|  | @ -1,4 +1,47 @@ | ||||||
| from setuptools import setup | from setuptools import setup, find_packages | ||||||
|  | import os | ||||||
| 
 | 
 | ||||||
| setup() | version = '2.0' | ||||||
| 
 | 
 | ||||||
|  | long_description = ( | ||||||
|  |     open('README.md').read() | ||||||
|  |     + '\n' + | ||||||
|  |     'Contributors\n' | ||||||
|  |     '============\n' | ||||||
|  |     + '\n' + | ||||||
|  |     open('CONTRIBUTORS.txt').read() | ||||||
|  |     + '\n' + | ||||||
|  |     open('CHANGES.txt').read() | ||||||
|  |     + '\n') | ||||||
|  | 
 | ||||||
|  | setup(name='py-scopes', | ||||||
|  |       version=version, | ||||||
|  |       description="combined triple and event storage for the cco application platform", | ||||||
|  |       long_description=long_description, | ||||||
|  |       # Get more strings from | ||||||
|  |       # http://pypi.python.org/pypi?%3Aaction=list_classifiers | ||||||
|  |       classifiers=[ | ||||||
|  |         "Programming Language :: Python", | ||||||
|  |         ], | ||||||
|  |       keywords='', | ||||||
|  |       author='cyberconcepts.org team', | ||||||
|  |       author_email='team@cyberconcepts.org', | ||||||
|  |       url='http://www.cyberconcepts.org', | ||||||
|  |       license='MIT', | ||||||
|  |       packages=find_packages(), | ||||||
|  |       #package_dir = {'': 'src'}, | ||||||
|  |       #namespace_packages=['cco'], | ||||||
|  |       include_package_data=True, | ||||||
|  |       zip_safe=False, | ||||||
|  |       install_requires=[ | ||||||
|  |           'setuptools', | ||||||
|  |           'transaction', | ||||||
|  |           'psycopg2-binary', | ||||||
|  |           'SQLAlchemy', | ||||||
|  |           'zope.sqlalchemy', | ||||||
|  |           # -*- Extra requirements: -*- | ||||||
|  |       ], | ||||||
|  |       entry_points=""" | ||||||
|  |       # -*- Entry points: -*- | ||||||
|  |       """, | ||||||
|  |       ) | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue