diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 61f1a9a..0000000 --- a/.env.sample +++ /dev/null @@ -1,5 +0,0 @@ -BASEURL="https:///v1" # Base EndPoint of Space And Time -USERID="" # UserID required for authentication and authorization -JOINCODE="" # Space and Time Join Code which can be got from the SxT release team -SCHEME="ed25519" # The key scheme or algorithm required for key generation. -PREFIX="" # Optional Prefix Parameter for signature generation \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7b3c007..b9e4d28 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ __pycache__/ *.py[cod] *$py.class - +resources/ +users/ +tests/ +publish.sh # C extensions *.so diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..97e116c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include src/spaceandtime/apiversions.json \ No newline at end of file diff --git a/README.MD b/README.MD index 3349462..dd467b1 100644 --- a/README.MD +++ b/README.MD @@ -2,11 +2,11 @@ -## python-sxt-sdk (v.0.0.1) +## Python Space and Time SDK -Python SDK for Space and Time Gateway (python version >= 3.6) +Python SDK for Space and Time Gateway (python version >= 3.11) @@ -14,278 +14,237 @@ Python SDK for Space and Time Gateway (python version >= 3.6) -_Note: Before running the code, rename `.env.sample` to `.env` and ensure that your credentials are setup in the `.env` file properly_ +_Note: The recommended approach to storing keys is using an `.env` file. +For more information, please see: https://docs.spaceandtime.io/docs/dotenv_ ```sh -pip install -r requirements.txt +pip install spaceandtime ``` -The code in `main.py` demonstrates how to call the SDK + -## Features - - - -- **Sessions** - -The SDK implements persistent storage in - -1. _File based sessions_ - - - -- **Encryption** - -It supports ED25519 Public key encryption for Biscuit Authorization and securing data in the platform. - - - -- **SQL Support** - - - Support for DDL : ```creating own schema(namespace), tables, altering and deleting tables``` - - - Support for DML: ```CRUD``` operation support. - - - Support for SQL: ```select``` operations support. - - - Support for SQL Views - - - -- **Platform Discovery** - - For fetching metadata and information about the database resources. - - - Namespaces - - Tables - - Table Columns - - Table Indexes - - Table Primary Keys - - Table Relationships - - Table Primary Key References - - Table Foreign Key References - - - - -## Examples - - - -- **Initializing the SDK** +### Getting Started ```python +# Initializing the Space and Time usage. +from spaceandtime import SpaceAndTime - # Initializing the Space and Time SDK for use. - - from spaceandtimesdk import SpaceAndTimeSDK - SpaceAndTimeInit = SpaceAndTimeSDK() +sxt = SpaceAndTime() +sxt.authenticate() +success, rows = sxt.execute_query( + 'select * from POLYGON.BLOCKS limit 5') +print( rows ) ``` - - -- **Authenticating with the Space and Time Platform** +The authentication without arguments will seek out a default `.env` file and use credentials found there. It also supports passing in a specific ```filepath.env``` or simply supplying ```user_id``` and ```private_key```. - +The generated ``access_token`` is valid for 25 minutes and the ``refresh_token`` for 30 minutes. -Make sure to save your **private key** used in authentication and biscuit generation or else you will not be able to have access to the user and the tables created using the key. - - - -The generated ``AccessToken`` is valid for 25 minutes and the ``RefreshToken`` for 30 minutes. - - +There are a number of convenience features in the SDK for handling return data sets. By default, data sets are returned as a list-of-dictionaries, however can be easily turned into other formats, such as CSV. ```python - # Authenticate yourself using the Space and Time SDK. - authenticate_token_data = SpaceAndTimeInit.authenticate_user() - - authenticate_token_response = authenticate_token_data["response"] - authenticate_token_error = authenticate_token_data["error"] - - print("Response: ", authenticate_token_response) - print("Error: ", authenticate_token_error) - +# use triple-quotes to insert more complicated sql: +success, rows = sxt.execute_query(""" + SELECT + substr(time_stamp,1,7) AS YrMth + ,count(*) AS block_count + FROM polygon.blocks + GROUP BY YrMth + ORDER BY 1 desc """ ) + +# print results as CSV +print( sxt.json_to_csv(rows) ) ``` -- **Generating Biscuits** +More data transforms will be added over time. - +### SXTUser Object -For the generation of biscuits for your Python SDK that is required for performing the SQL Operations to interact with the SxT Data Warehouse, please refer to the [biscuit-cli](https://www.biscuitsec.org/docs/Usage/cli/) documentation which is a CLI tool that can be used for generating biscuits. - - - -- **DDL, DML and DQL** - - **Note**: - - To create a new **schema**, ``ddl_create`` permission is needed. - - +All SQL requests are handled by an authenticated user object. The ```sxt``` wrapper object contains a 'default user' object for simplicity, managing and authenticating as needed. It is however exposed if needed: ```python - # Create a Schema - create_schema_data = SpaceAndTimeInit.CreateSchema("CREATE SCHEMA ETH") - - create_schema_response = create_schema_data["response"] - create_schema_error = create_schema_data["error"] - - print("Response: ", create_schema_response) - print("Error: ", create_schema_error) - +print( sxt.user ) +``` - # Only for Create Table Queries - # for DROP, use DDL() - ddl_create_table_data = SpaceAndTimeInit.DDLCreateTable("CREATE TABLE ETH.TESTTABLE (id INT PRIMARY KEY, test VARCHAR)", "permissioned", publicKey, biscuit) +You can also manage users directly. This allows you to load and authenticate multiple users at a time, in case your application needs to manage several accounts. - ddl_create_table_response = ddl_create_table_data["response"] - ddl_create_table_error = ddl_create_table_data["error"] +_**All interaction with the network requires an authenticated user.**_ - print("Response: ", ddl_create_table_response) - print("Error: ", ddl_create_table_error) +The user object owns the authenticated connection to the network, so all requests are submitted by a user object. +```python +# Multiple Users +from spaceandtime import SXTUser - # For DROP - ddl_data = SpaceAndTimeInit.DDL("DROP TABLE ETH.TESTTABLE", biscuit) +suzy = SXTUser('./users/suzy.env', authenticate=True) - ddl_data_response = ddl_data["response"] - ddl_data_error = ddl_data["error"] +bill = SXTUser() +bill.load() # defaults to "./.env" +bill.authenticate() - print("Response: ", ddl_data_response) - print("Error: ", ddl_data_error) +# new user +pat = SXTUser(user_id='pat') +pat.new_keypair() +pat.api_url = suzy.api_url +pat.save() # <-- Important! don't lose keys! +pat.authenticate() +``` +There is also some capability to administer your subscription using the SDK. This capability will expand more over time. - # DML - # Use DML() to insert, update, delete and merge queries - dml_data = SpaceAndTimeInit.DML("ETH.TESTTABLE", "INSERT INTO ETH.TESTTABLE VALUES(5,'x5')", biscuit) +```python +# suzy invites pat to her subcription: +if suzy.user_type in ['owner','admin']: + joincode = suzy.generate_joincode() + success, results = pat.join_subscription(joincode) + print( results ) +``` - dml_data_response = dml_data["response"] - dml_data_error = dml_data["error"] - print("Response: ", dml_data_response) - print("Error: ", dml_data_error) +### DISCOVERY - # DQL for selecting content from the blockchain tables. - dql_data = SpaceAndTimeInit.DQL("ETH.TESTTABLE", "SELECT * FROM ETH.TESTTABLE", biscuit) +There are several discovery functions that allow insight to the Space and Time network metadata. - dql_data_response = dql_data["response"] - dql_data_error = dql_data["error"] - print("Response: ", dql_data_response) - print("Error: ", dql_data_error) +```python +# discovery calls provide network information +success, schemas = sxt.discovery_get_schemas() +print(f'There are {len(schemas)} schemas currently on the network.') +print(schemas) ``` - - -- **DISCOVERY** - Discovery SDK calls need a user to be logged in. +### Creating Tables - +The SDK abstracts away complexity from making a new table into a Table object. This object contains all needed components to be self-sufficient _EXCEPT_ for an authenticated user object, which is required to submit the table creation to the network. ```python - # List Namespaces - namespace_data = SpaceAndTimeInit.get_namespaces() - - namespace_response = namespace_data["response"] - namespace_error = namespace_data["error"] - - print("Response: ", namespace_response) - print("Error: ", namespace_error) - +# Create a table +from spaceandtime import SXTTable, SXTTableAccessType + +tableA = SXTTable(name = "SXTTEMP.MyTestTable", + new_keypair = True, + default_user = sxt.user, + logger = sxt.logger, + access_type = SXTTableAccessType.PERMISSSIONED) + +tableA.create_ddl = """ + CREATE TABLE {table_name} + ( MyID int + , MyName varchar + , MyDate date + , Primary Key(MyID) + ) {with_statement} +""" + +# create new biscuits for your table +tableA.add_biscuit('read', tableA.PERMISSION.SELECT ) + +tableA.add_biscuit('write', tableA.PERMISSION.SELECT, + tableA.PERMISSION.INSERT, + tableA.PERMISSION.UPDATE, + tableA.PERMISSION.DELETE, + tableA.PERMISSION.MERGE ) + +tableA.add_biscuit('admin', tableA.PERMISSION.ALL ) + +tableA.save() # <-- Important! Don't lose your keys! + +# create with assigned default user +success, results = tableA.create() +``` - # List Tables in a given namespace - # Possible scope values - ALL = all tables, PUBLIC = non-permissioned tables, PRIVATE = tables created by a requesting user - get_tables_data = SpaceAndTimeInit.get_tables("ALL","ETH") - get_tables_response = get_tables_data["response"] - get_tables_error = get_tables_data["error"] +The ```table.create_ddl``` and ```table.with_statement``` property will substitute {names} to replace with class values. In the example above, the ```{table_name}``` will be replace with ```tableA.table_name``` and the ```{with_statement}``` will be replaced with a valid WITH statement, itself with substitutions for ```{public_key}``` and ```{access_type}```. - print("Response: ", get_tables_response) - print("Error: ", get_tables_error) +Note, if the ```{with_statement}``` placeholder is absent, the table object will attempt to add dynamically. - - # List columns for a given table in a namespace - get_table_column_data = SpaceAndTimeInit.get_table_columns("TESTTABLE", "ETH") +When adding biscuits, they can either be added as string tokens, or as SXTBiscuit type objects, or as a list of either. - get_table_column_response = get_table_column_data["response"] - get_table_column_error = get_table_column_data["error"] +The ```tableA.save()``` function will save all keys, biscuits, and table attributes to a shell-friendly format, such that you could execute the file in shell and load all values to environment variables, for use in other scripting. For example, - print("Response: ", get_table_column_response) - print("Error: ", get_table_column_error) +```sh +Stephen~$ . ./table--SXTTEMP.New_TableName.sql +Stephen~$ echo $TABLE_NAME + SXTTEMP.New_TableName +``` +This allows table files created in the python SDK to be used with the SxT CLI. - - # List table index for a given table in a namespace - get_table_indexes_data = SpaceAndTimeInit.get_table_indexes("TESTTABLE", "ETH") - get_table_indexes_response = get_table_indexes_data["response"] - get_table_indexes_error = get_table_indexes_data["error"] +### Insert, Deletes, and Selects - print("Response: ", get_table_indexes_response) - print("Error: ", get_table_indexes_error) +There are helper functions to assist quickly adding, removing, and selecting data in the table. Note, these are just helper functions for the specific table object - for more general SQL interface, use the ```sxt.execute_query()``` function. +```python +from pprint import pprint # for better viewing of data - # List table primary key for a given table in a namespace - get_table_primary_keys_data = SpaceAndTimeInit.get_table_primary_keys("TESTTABLE", "ETH") +# generate some dummy data +cols = ['MyID','MyName','MyDate'] +data = [[i, chr(64+i), f'2023-09-0{i}'] for i in list(range(1,10))] - get_table_primary_keys_response = get_table_primary_keys_data["response"] - get_table_primary_keys_error = get_table_primary_keys_data["error"] +# insert into the table +tableA.insert(columns=cols, data=data) - print("Response: ", get_table_primary_keys_response) - print("Error: ", get_table_primary_keys_error) +# select out again, just for fun +success, rows = tableA.select() +pprint( rows ) - - # List table relations for a namespace and scope - get_table_relationship_data = SpaceAndTimeInit.get_table_relationships("PRIVATE", "ETH") +tableA.delete(where='MyID=6') - get_table_relationship_response = get_table_relationship_data["response"] - get_table_relationship_error = get_table_relationship_data["error"] +# one less than last time +success, rows = tableA.select() +pprint( rows ) +``` - print("Response: ", get_table_relationship_response) - print("Error: ", get_table_relationship_error) +### Creating Views - - # List table primary key references for a table, column and a namespace - primary_key_reference_data = SpaceAndTimeInit.get_primary_key_references("TESTTABLE", "TEST", "ETH") +The SXTView object inherits from the same base class as SXTTable, so the two are very similar. One notable difference is a view's need for a biscuit for each table referenced. To add clarity and remind of this requirement, a view contains a ```table_biscuit``` property. Also note that views don't need DML PERMISSIONS, like insert or delete. - primary_key_reference_response = - primary_key_reference_data["response"] - primary_key_reference_error = primary_key_reference_data["error"] +```python +# create a view +from spaceandtime import SXTView - print("Response: ", primary_key_reference_response) - print("Error: ", primary_key_reference_error) +viewB = SXTView('SXTTEMP.MyTest_Odds', + default_user=tableA.user, + private_key=tableA.private_key, + logger=tableA.logger) - - # List table foreign key references for a table, column and a namespace - foreign_key_reference_data = SpaceAndTimeInit.get_foreign_key_references("TESTTABLE", "TEST", "ETH") +viewB.add_biscuit('read', viewB.PERMISSION.SELECT) +viewB.add_biscuit('admin', viewB.PERMISSION.ALL) +viewB.table_biscuit = tableA.get_biscuit('admin') - foreign_key_reference_response = foreign_key_reference_data["response"] - foreign_key_reference_error = foreign_key_reference_data["error"] +viewB.create_ddl = """ + CREATE VIEW {view_name} + {with_statement} + AS + SELECT * + FROM """ + tableA.table_name + """ + WHERE MyID in (1,3,5,7,9) """ - print("Response: ", foreign_key_reference_response) - print("Error: ", foreign_key_reference_error) +viewB.save() # <-- Important! don't lose keys! +success, results = viewB.create() ``` - - -- **Storage** +We've used the same private key for the table and the view. This is NOT required, but is convenient if you are building a view atop only one table. - For File Storage, the following methods are available +Each object comes with a pre-built ```recommended_filename``` which acts as the default for ```save()``` and ```load()```. ```python +print( tableA.recommended_filename ) +print( viewB.recommended_filename ) +print( suzy.recommended_filename ) +``` - # File - - SpaceAndTimeInit.write_to_file(AccessToken, RefreshToken, AccessTokenExpires, RefreshTokenExpires) - - SpaceAndTimeInit.read_file_contents() +Once you're done, it's best practice to clean up. +```python +viewB.drop() +tableA.drop() ``` diff --git a/authorize.datalog b/authorize.datalog deleted file mode 100644 index 5b76d3a..0000000 --- a/authorize.datalog +++ /dev/null @@ -1,10 +0,0 @@ -// Sample Datalog file for biscuits - -sxt:capability("ddl_create", "eth.testtable"); -sxt:capability("ddl_alter", "eth.testtable"); -sxt:capability("ddl_drop", "eth.testtable"); -sxt:capability("dml_insert", "eth.testtable"); -sxt:capability("dml_update", "eth.testtable"); -sxt:capability("dml_delete", "eth.testtable"); -sxt:capability("dml_merge", "eth.testtable"); -sxt:capability("dql_select", "eth.testtable"); diff --git a/keygen.py b/keygen.py deleted file mode 100644 index 5fe352b..0000000 --- a/keygen.py +++ /dev/null @@ -1,44 +0,0 @@ - -import ed25519 -import binascii -import base64 -import os -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -def generate_keys(): - seed = os.urandom(32) - private_key = Ed25519PrivateKey.from_private_bytes(seed) - public_key = private_key.public_key() - - private_key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, - encryption_algorithm=serialization.NoEncryption() - ) - - public_key_bytes = public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw - ) - - # Encode the private key, and public key in Base64 format - b64_private_key = base64.b64encode(private_key_bytes).decode() - b64_public_key = base64.b64encode(public_key_bytes).decode() - - hex_private_key = binascii.hexlify(private_key_bytes).decode() - hex_public_key = binascii.hexlify(public_key_bytes).decode() - - generated_keys = { - "ed25519_private_key": private_key_bytes, - "ed25519_public_key": public_key_bytes, - "b64_private_key": b64_private_key, - "b64_public_key": b64_public_key, - "hex_private_key":hex_private_key, - "hex_public_key":hex_public_key, - } - - return generated_keys - - -exported_keys = generate_keys() \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 1597a23..0000000 --- a/main.py +++ /dev/null @@ -1,192 +0,0 @@ -# File containing Examples to run the SDK. - -from spaceandtimesdk import SpaceAndTimeSDK -import os -from dotenv import load_dotenv -import re -import base64 -import binascii -from Cryptodome.Cipher import AES - -load_dotenv() - -from keygen import exported_keys - -SpaceAndTimeInit = SpaceAndTimeSDK() - -join_code = os.getenv('JOINCODE') -prefix = os.getenv('PREFIX') -user_id = os.getenv('USERID') -scheme = os.getenv('SCHEME') - -tokens = SpaceAndTimeInit.read_file_contents() -access_token, refresh_token = tokens['accessToken'], tokens['refreshToken'] - -""" -AUTHENTICATION BLOCK -""" - -if access_token: - validate_token_data = SpaceAndTimeInit.validate_token() - validate_token_response, validate_token_error = validate_token_data["response"], validate_token_data["error"] - if validate_token_response: - print('Valid access token provided.') - print('Valid User ID: ', validate_token_response) - else: - refresh_token_data = SpaceAndTimeInit.refresh_token() - refresh_token_response, refresh_token_error = refresh_token_data["response"], refresh_token_data["error"] - print('Refreshed Tokens: ', refresh_token_response) - - if not refresh_token_response: - authenticate_token_data = SpaceAndTimeInit.authenticate_user() - authenticate_token_response, authenticate_token_error = authenticate_token_data["response"], authenticate_token_data["error"] - if not authenticate_token_error: - print(authenticate_token_response) - else: - print('Invalid user tokens provided.') - print(authenticate_token_error) - -else: - authenticate_token_data = SpaceAndTimeInit.authenticate_user() - authenticate_token_response, authenticate_token_error = authenticate_token_data["response"], authenticate_token_data["error"] - - if not authenticate_token_error: - print(authenticate_token_response) - else: - print('Invalid user tokens provided.') - print(authenticate_token_error) - -# Authentication APIs - -# Check if a UserId is already in use -check_user_identifier_data = SpaceAndTimeInit.check_user_identifier(user_id) -print(check_user_identifier_data) - -# Authenticate yourself with Space And Time using the SDK -authenticate_token_data = SpaceAndTimeInit.authenticate_user() -print(authenticate_token_data) - -# Refresh Your Tokens -refresh_tokens_data = SpaceAndTimeInit.refresh_token() -print(refresh_tokens_data) - -# Rotate Tokens -rotate_token_data = SpaceAndTimeInit.rotate_tokens() -print(rotate_token_data) - -# Validate your AccessToken by getting back the UserId -validate_token_data = SpaceAndTimeInit.validate_token() -print(validate_token_data) - -# Logout or end your authenticated session by using a RefreshToken -logout_data = SpaceAndTimeInit.logout() -print(logout_data) - - -scope = "ALL" -namespace = "ETHEREUM" -owned = True -column = "BLOCK_NUMBER" -table_name = "FUNGIBLETOKEN_WALLET" -foreign_key_table_name = "BLOCKS" - -# Resource Discovery APIs - -# List the namespaces -namespace_data = SpaceAndTimeInit.get_namespaces() -print(namespace_data) - -# List the table of a given namespace -# Scope value options - ALL = all tables, PUBLIC = non-permissioned tables, PRIVATE = tables created by a requesting user -get_tables_data = SpaceAndTimeInit.get_tables(scope, namespace) -print(get_tables_data) - -# List table column metadata -get_table_column_data = SpaceAndTimeInit.get_table_columns(table_name, namespace) -print(get_table_column_data) - -# List table Index metadata -get_table_indexes_data = SpaceAndTimeInit.get_table_indexes(table_name, namespace) -print(get_table_indexes_data) - -# List table primarykey metadata -get_table_primary_keys_data = SpaceAndTimeInit.get_table_primary_keys(table_name, namespace) -print(get_table_primary_keys_data) - -# List table relationship metadata including table, column and primary key references for all tables of a namespace -get_table_relationship_data = SpaceAndTimeInit.get_table_relationships(scope, namespace) -print(get_table_relationship_data) - -# List all primary key references by the provided foreign key reference -primary_key_reference_data = SpaceAndTimeInit.get_primary_key_references(table_name, column, namespace) -print(primary_key_reference_data) - -# List all foreign key references referencing the provided primary key -foreign_key_references_data = SpaceAndTimeInit.get_foreign_key_references(foreign_key_table_name, column, namespace) -print(foreign_key_references_data) - -# Core SQL APIs - -namespace = "ETH" -table_name = "PYTHTEST1" -# table_name = "PYTHSXT2" - -biscuit_array = ["EpABCiYKD..."] -resource_id = f"{namespace}.{table_name}" - -create_sql_text = "CREATE TABLE ETH.PYTHTEST1 (ID INT PRIMARY KEY, TEST VARCHAR)" -select_sql_text = "SELECT * FROM ETH.PYTHTEST1" -drop_sql_text = "DROP TABLE ETH.PYTHTEST1" -insert_sql_text = "INSERT INTO ETH.PYTHTEST1 VALUES(1, 'X1')" - - -main_public_key = exported_keys["hex_public_key"] -main_private_key = exported_keys["hex_private_key"] -access_type = "public_append" -biscuit_token = "" - -# Create a Schema -create_schema_sql_text = "CREATE SCHEMA ETH" -create_schema_data = SpaceAndTimeInit.CreateSchema(create_schema_sql_text) -print(create_schema_data) - -# DDL -# Create a table -ddl_create_table_data = SpaceAndTimeInit.DDLCreateTable(create_sql_text, access_type, main_public_key, biscuit_token, biscuit_array) -print(ddl_create_table_data) - -# Drop a table -ddl_data = SpaceAndTimeInit.DDL(resource_id, drop_sql_text, biscuit_token, biscuit_array) -print(ddl_data) - -# DML -# Insert, update, merge and delete contents of a table -dml_data = SpaceAndTimeInit.DML(insert_sql_text, biscuit_token, biscuit_array) -print(dml_data) - -# DQL -# Select query and selects all if row_count = 0 -dql_data = SpaceAndTimeInit.DQL(resource_id, select_sql_text, biscuit, biscuit_array) -print(dql_data) - -# Views API - -parameters_request = [ - { - "name":"BLOCK_NUMBER", - "type":"Integer" - } -] - -namespace = "ETH" -table_name = "BLOCK" -resource_id = f"{namespace}.{table_name}" -view_text = "SELECT * FROM ETH.BLOCK WHERE BLOCK_NUMBER={{BLOCK_NUMBER}}" -view_name = "block-view-pyth3" -description = "display the blocks by BLOCK NUMBER" -update_description = "block view update 3" -publish = True - - -# Execute a view -print(SpaceAndTimeInit.execute_view(view_name, parameters_request)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6dabc62 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +# pyproject.toml + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "spaceandtime" +version = "1.1.5" +description = "SDK for Space and Time verifiable database" +authors = [{ name = "Stephen Hilton", email = "stephen.hilton@spaceandtime.io" }] +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = ["space and time", "sxt", "spaceandtime", "verifiable", "database", "web3", "blockchain", "data warehouse", "data"] +dependencies = [ + "requests >= 2.31.0", + "pynacl >= 1.5.0", + "python-dotenv >= 1.0.0", + "biscuit-python >= 0.2.0" +] + + +[project.optional-dependencies] +dev = ["pip-tools", "pytest"] + +[project.urls] +Homepage = "https://spaceandtime.io" +Docs = "https://docs.spaceandtime.io" +Documentation = "https://docs.spaceandtime.io" +Github = "https://github.com/spaceandtimelabs/SxT-Python-SDK" + +[project.scripts] +sxtlogin = "spaceandtime.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] +# include = ["my_package*"] # package names should match these glob patterns (["*"] by default) +# exclude = ["my_package.tests*"] # exclude packages matching these glob patterns (empty by default) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d2d0f65..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -cryptography==40.0.2 -ed25519==1.5 -PyNaCl==1.5.0 -pycryptodomex==3.18.0 -python-dotenv==1.0.0 -Requests==2.31.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2152059 --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from distutils.core import setup +setup( + name = 'spaceandtime', # How you named your package folder (MyLib) + packages = ['spaceandtime'], # Chose the same as "name" + version = '0.0.2', # Start with a small number and increase it with every change you make + license='MIT', # Chose a license from here: https://help.github.com/articles/licensing-a-repository + description = 'Space and Time Python SDK', + author = 'Stephen Hilton', + author_email = 'stephen.hilton@spaceandtime.io', + url = 'https://github.com/spaceandtimelabs/SxT-Python-SDK', + download_url = 'https://github.com/user/reponame/archive/v_01.tar.gz', + keywords = ['Space and Time', 'SXT', 'Python', 'SDK', 'Web3', 'Blockchain'], + install_requires=[ + 'validators', + 'beautifulsoup4', + ], + classifiers=[ + 'Development Status :: 3 - Alpha', # either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" + 'Intended Audience :: Developers', # Define that your audience are developers + 'Topic :: Software Development :: Build Tools', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + ], +) \ No newline at end of file diff --git a/spaceandtimesdk.py b/spaceandtimesdk.py deleted file mode 100644 index 0e02a02..0000000 --- a/spaceandtimesdk.py +++ /dev/null @@ -1,640 +0,0 @@ -import requests -import ed25519 -import base64 -import binascii -import validation -import json -from datetime import datetime -import os -from nacl.signing import SigningKey, VerifyKey -from nacl.exceptions import BadSignatureError -from dotenv import load_dotenv, set_key, get_key - -from keygen import exported_keys - -class SpaceAndTimeSDK: - def __init__(self): - self.base_url = os.getenv('BASEURL') - - """ Authentication APIs """ - # Check if a User is using the ID - def check_user_identifier(self, user_id): - try: - - api_endpoint = f"{self.base_url}/auth/idexists/{user_id}" - headers = {"accept": "application/json"} - response = requests.get(api_endpoint,headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - # Generates an AuthCode given an userId, prefix and joinCode - def generate_auth_code(self, user_id, prefix, join_code): - try: - validation.check_prefix_and_joincode(prefix, join_code) - api_endpoint = f"{self.base_url}/auth/code" - payload = { - 'userId': user_id, - 'prefix': prefix, - 'joinCode': join_code, - } - - headers = { - "accept": "application/json", - "content-type": "application/json" - } - - response = requests.post(api_endpoint, json=payload, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - # Generate a signature using an authcode and privatekey - def signature_generation(self, auth_code, priv_key_arg, public_key_arg): - - - message = bytes(auth_code, 'utf-8') - private_key = self.signing_keys_convert(priv_key_arg) - - signature = private_key.sign(message) - - hex_signature = binascii.hexlify(signature).decode()[:128] - keys_content_object = { - 'b64_private_key':priv_key_arg, - 'b64_public_key':public_key_arg, - 'hex_signature':hex_signature - } - - return keys_content_object - - # Generates access and refresh tokens - def generate_tokens(self, user_id, auth_code, private_key, public_key, scheme="ed25519"): - - try: - validation.is_base64(private_key) - validation.is_base64(public_key) - - api_endpoint = f"{self.base_url}/auth/token" - signature_contents = self.signature_generation(auth_code, private_key, public_key) - b64_private_key, b64_public_key, hex_signature = signature_contents.values() - - payload = { - 'userId': user_id, - 'authCode': auth_code, - 'signature': hex_signature, - 'key': b64_public_key, - 'scheme': scheme - } - - headers = { - "accept": "application/json", - "content-type": "application/json" - } - - response = requests.post(api_endpoint, json=payload, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - def signing_keys_convert(self, private_key_arg): - - # Decoding the Base64 Key. - decoded_private_key = base64.b64decode(private_key_arg) - - # Converting the decoded Base64 Key to a Signing Key. - private_signing_key = SigningKey(decoded_private_key) - - return private_signing_key - - def read_file_contents(self): - with open("session.txt") as file: - access_token = file.readline().strip() - refresh_token = file.readline().strip() - access_token_expires = file.readline().strip() - refresh_token_expires = file.readline().strip() - - token_obj = { - "accessToken": access_token, - "refreshToken": refresh_token, - "accessTokenExpires": access_token_expires, - "refreshTokenExpires":refresh_token_expires - } - - return token_obj - - def write_to_file(self, accessToken, refreshToken, accessTokenExpires, refreshTokenExpires): - with open("session.txt", "w") as file: - file.write(accessToken + "\n") - file.write(refreshToken + "\n") - file.write(str(accessTokenExpires) + "\n") - file.write(str(refreshTokenExpires) + "\n") - - def user_id_exists(self): - user_id = os.getenv('USERID') - user_id_response = self.check_user_identifier(user_id)["response"] - - final_result = True if (user_id_response == 'true') else False - return final_result - - def authenticate_user(self, private_key_arg="", public_key_arg=""): - - main_public_key = exported_keys["b64_public_key"] if public_key_arg == "" else public_key_arg - main_private_key = exported_keys["b64_private_key"] if private_key_arg == "" else private_key_arg - - pub_key = main_public_key if os.getenv('PUBLICKEY') is None else os.getenv('PUBLICKEY') - priv_key = main_private_key if os.getenv('PRIVATEKEY') is None else os.getenv('PRIVATEKEY') - - if not self.user_id_exists(): - return self.authenticate(priv_key, pub_key) - - #2) If user_id already exists, then authenticate - else: - return self.authenticate(priv_key, pub_key) - - - #Creates Access and Refresh Tokens for Users - def authenticate(self, priv_key, pub_key, prefix=""): - - user_id = os.getenv('USERID') - join_code = os.getenv('JOINCODE') - scheme = os.getenv('SCHEME') - - auth_code_data = self.generate_auth_code(user_id, prefix, join_code) - auth_code_response, auth_code_error = auth_code_data["response"], auth_code_data["error"] - if auth_code_error: raise Exception(auth_code_error) - - auth_code = json.loads(auth_code_response)["authCode"] - - required_private_key = priv_key - required_public_key = pub_key - - tokens_data = self.generate_tokens(user_id, auth_code, required_private_key, required_public_key, scheme) - tokens_response, tokens_error = tokens_data["response"], tokens_data["error"] - if tokens_error: raise Exception(tokens_error) - - jsonResponse = json.loads(tokens_response) - - # Writing Token response to file - self.write_to_file(jsonResponse["accessToken"], jsonResponse["refreshToken"], jsonResponse["accessTokenExpires"], jsonResponse["refreshTokenExpires"]) - - # Writing key values to ENV - - set_key(".env", "PUBLICKEY", required_public_key) - set_key(".env", "PRIVATEKEY", required_private_key) - - return {"response" : jsonResponse, "error" : tokens_error} - - # Allows the user to generate new tokens if time left is less than or equal to 2 minutes OR gives them back their unexpired tokens. - def rotate_tokens(self): - MINIMUM_TOKEN_SECONDS = 120 - - tokens = self.read_file_contents() - access_token, refresh_token = tokens['accessToken'], tokens['refreshToken'] - access_token_expires, refresh_token_expires = int(tokens['accessTokenExpires']), int(tokens['refreshTokenExpires']) - - authentication_tokens = [access_token, refresh_token] - - current_milliseconds = int(datetime.timestamp(datetime.now()) * 1000) - - access_token_expiry_datetime = datetime.fromtimestamp((current_milliseconds + access_token_expires) / 1000) - refresh_token_expiry_datetime = datetime.fromtimestamp((current_milliseconds + refresh_token_expires) / 1000) - - access_token_expiry_duration = round((access_token_expiry_datetime - datetime.now()).total_seconds()) - refresh_token_expiry_duration = round((refresh_token_expiry_datetime - datetime.now()).total_seconds()) - - should_refresh_token = access_token_expiry_duration <= MINIMUM_TOKEN_SECONDS - should_authenticate_user = refresh_token_expiry_duration <= MINIMUM_TOKEN_SECONDS - - if should_refresh_token: - if should_authenticate_user: - token_response, token_error = self.authenticate_user() - return token_response, token_error - - refresh_token_response, refresh_token_error = self.refresh_token() - return refresh_token_response, refresh_token_error - - return authentication_tokens, None - - # Checks if your accessToken value is valid and gives you the UserID on success. - def validate_token(self): - try: - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - api_endpoint = f"{self.base_url}/auth/validtoken" - - headers = { - "accept": "application/json", - "content-type": "application/json", - "Authorization" : f'Bearer {access_token}' - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - #Refresh your Access and Refresh Tokens by providing a valid RefreshToken - def refresh_token(self): - try: - tokens = self.read_file_contents() - refresh_token = tokens["refreshToken"] - - api_endpoint = f"{self.base_url}/auth/refresh" - headers = { - "accept": "application/json", - "Authorization" : f'Bearer {refresh_token}' - } - - response = requests.post(api_endpoint, headers=headers) - response.raise_for_status() - jsonResponse = response.json() - - # Writing Token response to file - self.write_to_file(jsonResponse["accessToken"], jsonResponse["refreshToken"], jsonResponse["accessTokenExpires"], jsonResponse["refreshTokenExpires"]) - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - - #Logout or end an authenticated session. - def logout(self): - try: - tokens = self.read_file_contents() - refresh_token = tokens["refreshToken"] - - api_endpoint = f'{self.base_url}/auth/logout' - headers = { - "accept": "application/json", - "Authorization" : f'Bearer {refresh_token}' - } - - response = requests.post(api_endpoint, headers=headers) - response.raise_for_status() - if response.status_code == 200: print('User has been logged out.') - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - """ Resource Discovery APIs """ - - # Fetch the namespace metadata - def get_namespaces(self): - try: - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - api_endpoint = f"{self.base_url}/discover/namespace" - - headers = { - "accept": "application/json", - "Authorization" : f'Bearer {access_token}' - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - # Fetch table metadata - def get_tables(self, scope, namespace): - try: - validation.validate_string(scope) - validation.try_parse_identifier(namespace) - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - api_endpoint = f"{self.base_url}/discover/table?scope={scope}&namespace={namespace}" - - headers = { - "accept": "application/json", - "Authorization":f'Bearer {access_token}' - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - def discovery_API_Request(self, namespace, table_name, api_endpoint): - try: - validation.try_parse_identifier(namespace) - validation.try_parse_identifier(table_name) - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - validation.validate_string(namespace) - - headers = { - "accept": "application/json", - "Authorization":f'Bearer {access_token}' - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - - # Fetch table column metadata - def get_table_columns(self, table_name, namespace): - api_endpoint = f"{self.base_url}/discover/table/column?namespace={namespace}&table={table_name}" - return self.discovery_API_Request(namespace, table_name, api_endpoint) - - # Fetch table indexes metadata - def get_table_indexes(self, table_name, namespace): - api_endpoint = f"{self.base_url}/discover/table/index?namespace={namespace}&table={table_name}" - return self.discovery_API_Request(namespace, table_name, api_endpoint) - - # Fetch table primary key metadata - def get_table_primary_keys(self, table_name, namespace): - api_endpoint = f"{self.base_url}/discover/table/primaryKey?namespace={namespace}&table={table_name}" - return self.discovery_API_Request(namespace, table_name, api_endpoint) - - # Fetch table relationship metadata for tables in a namespace - def get_table_relationships(self, scope, namespace): - try: - validation.validate_string(scope) - validation.try_parse_identifier(namespace) - - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - api_endpoint = f"{self.base_url}/discover/table/relations?namespace={namespace}&scope={scope}" - - headers = { - "accept": "application/json", - "Authorization":f'Bearer {access_token}' - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - def discovery_API_Request_References(self, namespace, table_name, column, api_endpoint): - try: - validation.try_parse_identifier(namespace) - validation.try_parse_identifier(table_name) - validation.validate_string(column) - - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - headers = { - "accept": "application/json", - "Authorization":f'Bearer {access_token}' - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - - #Fetch all primary keys referenced by a provided foreign key - def get_primary_key_references(self, table_name, column, namespace): - api_endpoint = f"{self.base_url}/discover/refs/primarykey?table={table_name}&namespace={namespace}&column={column}" - return self.discovery_API_Request_References(namespace, table_name, column, api_endpoint) - - # Fetch all foreign key referencing the provided primary key - def get_foreign_key_references(self, table_name, column, namespace): - api_endpoint = f"{self.base_url}/discover/refs/foreignkey?table={table_name}&namespace={namespace}&column={column}" - return self.discovery_API_Request_References(namespace, table_name, column, api_endpoint) - - """ CoreSQL """ - - @staticmethod - def convert_SQL_Text(sql_text, public_key, access_type): - return f'{str(sql_text)} WITH \"public_key={str(public_key)},access_type={access_type}\"' - - # Create a Schema - def CreateSchema(self, sql_text, biscuit_tokens=[], origin_app=""): - try: - validation.validate_string(sql_text) - - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - sql_text = sql_text.upper() - - api_endpoint = f"{self.base_url}/sql/ddl" - - payload = { - 'biscuits':biscuit_tokens, - 'sqlText': sql_text - } - - headers = { - "Authorization":f'Bearer {access_token}', - "Content-Type": "application/json", - "Accept": "application/json", - "originApp":origin_app - } - - response = requests.post(api_endpoint, json=payload, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - - # DDL - # Create a table with the given ResourceId - def DDLCreateTable(self, sql_text, access_type, public_key, biscuit, biscuit_tokens=[], origin_app=""): - try: - validation.validate_string(sql_text) - validation.validate_string(access_type) - - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - sql_text = sql_text.upper() - - api_endpoint = f"{self.base_url}/sql/ddl" - sql_text_payload = self.convert_SQL_Text(sql_text, public_key, access_type) - - payload = { - 'biscuits':biscuit_tokens, - 'sqlText': sql_text_payload - } - - headers = { - "Authorization":f'Bearer {access_token}', - "content-type": "application/json", - "Accept": "application/json", - "Biscuit": biscuit, - "originApp":origin_app - } - - response = requests.post(api_endpoint, json=payload, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - # Alter and drop a table with the given ResourceId - def DDL(self, sql_text, biscuit, biscuit_tokens=[], origin_app=""): - try: - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - validation.validate_string(sql_text) - - api_endpoint = f"{self.base_url}/sql/ddl" - - payload = { - "biscuits":biscuit_tokens, - "sqlText":sql_text - } - - headers = { - "accept": "application/json", - "content-type": "application/json", - "Authorization":f'Bearer {access_token}', - "Biscuit": biscuit, - "originApp":origin_app - } - - response = requests.post(api_endpoint, json=payload, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - # DML - # Perform insert, update, merge and delete with the given resourceId - - def DML(self, resource_id, sql_text, biscuit, biscuit_tokens=[], origin_app=""): - try: - validation.try_parse_identifier(resource_id) - validation.validate_string(sql_text) - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - api_endpoint = f"{self.base_url}/sql/dml" - - payload = { - "biscuits":biscuit_tokens, - "resourceId":resource_id.upper(), - "sqlText":sql_text - } - - headers = { - "accept": "application/json", - "content-type": "application/json", - "Authorization":f'Bearer {access_token}', - "Biscuit": biscuit, - "originApp":origin_app - } - - response = requests.post(api_endpoint, json=payload, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - # DQL - # Perform selection with the given resourceId - # If rowCount is 0, then the query will fetch all of the data - - def DQL(self, resource_id, sql_text, biscuit, biscuit_tokens=[], origin_app="", row_count=0): - try: - validation.try_parse_identifier(resource_id) - validation.validate_string(sql_text) - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - validation.validate_number(row_count) - - api_endpoint = f"{self.base_url}/sql/dql" - - if(row_count > 0): - payload = { - "biscuits":biscuit_tokens, - "resourceId":resource_id.upper(), - "sqlText":sql_text, - "rowCount":row_count - } - else: - payload = { - "biscuits":biscuit_tokens, - "resourceId":resource_id.upper(), - "sqlText":sql_text - } - - headers = { - "accept": "application/json", - "content-type": "application/json", - "Authorization":f'Bearer {access_token}', - "Biscuit": biscuit, - "originApp":origin_app - } - - response = requests.post(api_endpoint, json=payload,headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - - - """ Views """ - - # Execute a view - def execute_view(self, view_name, parameters_request={}): - try: - validation.validate_string(view_name) - tokens = self.read_file_contents() - access_token = tokens["accessToken"] - - param_endpoint = "" - param_string = "" - api_endpoint = f"{self.base_url}/sql/views/{view_name}" - - if(len(parameters_request) > 0): - for parameter_request_value in parameters_request: - param_name = parameter_request_value["name"] - param_type = parameter_request_value["type"] - - param_string += f"{param_name}={param_type}&" - - param_string = param_string[:-1] - param_endpoint += f"?params={param_string}" - - - api_endpoint += f"{param_endpoint}" - - headers = { - "accept": "application/json", - "Authorization":f'Bearer {access_token}', - } - - response = requests.get(api_endpoint, headers=headers) - response.raise_for_status() - return {"response" : response.text, "error" : None} - - except requests.exceptions.RequestException as error: - return {"response" : None, "error" : str(error)} - \ No newline at end of file diff --git a/src/spaceandtime/.env.sample b/src/spaceandtime/.env.sample new file mode 100644 index 0000000..3a2ed35 --- /dev/null +++ b/src/spaceandtime/.env.sample @@ -0,0 +1,4 @@ +API_URL="https://api.spaceandtime.app" +USERID="user123" +USER_PRIVATE_KEY="Td/IhCO/j3YWGbzvIgTTFgNVK60P4V2wFqho9ajN2Yc=" +USER_PUBLIC_KEY="jx2dxJGVfC1cppytY0zUGoITMn2UsYaVBvTBHh9Cjhs=" \ No newline at end of file diff --git a/src/spaceandtime/__init__.py b/src/spaceandtime/__init__.py new file mode 100644 index 0000000..59696ec --- /dev/null +++ b/src/spaceandtime/__init__.py @@ -0,0 +1,10 @@ +from .spaceandtime import SpaceAndTime +from .sxtbaseapi import SXTBaseAPI +from .sxtbiscuits import SXTBiscuit +from .sxtkeymanager import SXTKeyManager +from .sxtresource import SXTResource, SXTTable, SXTView, SXTMaterializedView +from .sxtuser import SXTUser +from .sxtenums import * +from .sxtexceptions import * + +__version__ = "1.1.5" diff --git a/src/spaceandtime/__main__.py b/src/spaceandtime/__main__.py new file mode 100644 index 0000000..6a4f358 --- /dev/null +++ b/src/spaceandtime/__main__.py @@ -0,0 +1,23 @@ +# this is called when someone runs the package using the -m option, i.e., +# python3 -m spaceandtime + +from spaceandtime import SpaceAndTime +from pathlib import Path +import sys + +def main(): + + # if env file path is supplied: + if len(sys.argv) >1: + envpath = Path(sys.argv[1]).resolve() + sxt = SpaceAndTime(envpath) + else: + sxt = SpaceAndTime() + + sxt.authenticate() + + print( f'Authenticated UserID: {sxt.user}\nAccess Token:\n {sxt.access_token}' ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/spaceandtime/apiversions.json b/src/spaceandtime/apiversions.json new file mode 100644 index 0000000..e56b655 --- /dev/null +++ b/src/spaceandtime/apiversions.json @@ -0,0 +1,35 @@ +{ + "auth/token": "v1", + "auth/refresh": "v1", + "auth/logout": "v1", + "auth/code": "v1", + "auth/validtoken": "v1", + "auth/idexists/{id}": "v1", + "auth/keys/code": "v1", + "auth/keys": "v1", + "sql": "v1", + "sql/ddl": "v1", + "sql/dml": "v1", + "sql/dql": "v1", + "encryption/sql/dql": "v1", + "encryption/sql/dml": "v1", + "encryption/configure": "v1", + "sql/queries/{queryName}": "v2", + "sql/queries-by-id/{queryId}": "v2", + "discover/namespace": "v1", + "discover/schema": "v2", + "discover/table": "v1", + "discover/table/column": "v2", + "discover/table/index": "v2", + "discover/table/primarykey": "v2", + "discover/table/relations": "v2", + "discover/refs/primarykey": "v2", + "discover/refs/foreignkey": "v2", + "discover/blockchains": "v2", + "discover/blockchains/{chainId}/schemas": "v2", + "discover/blockchains/{chainId}/meta": "v2", + "subscription": "v1", + "subscription/users": "v1", + "subscription/invite": "v1", + "subscription/invite/{joinCode}": "v1" +} \ No newline at end of file diff --git a/src/spaceandtime/spaceandtime.py b/src/spaceandtime/spaceandtime.py new file mode 100644 index 0000000..dc694f8 --- /dev/null +++ b/src/spaceandtime/spaceandtime.py @@ -0,0 +1,384 @@ +import logging, random, time, json +from datetime import datetime +from pathlib import Path +from .sxtuser import SXTUser +from .sxtresource import SXTTable, SXTView +from .sxtenums import * +from .sxtexceptions import * + +class SpaceAndTime: + + user: SXTUser = None + application_name: str = 'SxT-SDK' + network_calls_enabled:bool = True + discovery = None + + + def __init__(self, envfile_path=None, api_url=None, + user_id=None, user_private_key=None, + application_name='SxT-SDK', logger: logging.Logger = None): + """Create new instance of Space and Time SDK for Python""" + if logger: + self.logger = logger + else: + self.logger = logging.getLogger() + self.logger.setLevel(logging.INFO) + if len(self.logger.handlers) == 0: + self.logger.addHandler( logging.StreamHandler() ) + if application_name: self.application_name = application_name + self.logger.info('-'*30 + f'\nSpace and Time SDK initiated for {self.application_name}') + self.user = SXTUser(logger=self.logger) + return None + + @property + def access_token(self) -> str: + return self.user.access_token + + @property + def refresh_token(self) -> str: + return self.user.refresh_token + + + def authenticate(self, user:SXTUser = None): + """-------------------- + Authenticate user to Space and Time. Uses the default dotenv file to create a default user, if no other is supplied. + + Args: + user (SXTUser): (optional) SXTUser object used to authenticate, and set as default user. Creates new from default dotenv file if omitted. + + Returns: + bool: Success indicator + str: Access Token returned from Space and Time network + + Examples: + >>> sxt = spaceandtime() + >>> success, access_token = sxt.authenticate() + >>> print( success ) + True + >>> print( len(access_token) >= 64 ) + True + + """ + if not user: user = self.user + if self.network_calls_enabled: + success, rtn = user.authenticate() + else: + user.access_token = 'eyJ0eXBlI_this_is_a_pretend_access_token_it_will_not_really_work_4lXUgI5gIdk8T5Rb4Zlx8-Z1rlY-0y4pu5b4lIjh60wQY_g0vkteuQE0Or0cPDbstDnLg8uRpz5dM4GNg7QHYQ' + user.refresh_token = 'eyJ0eXBlI_this_is_a_pretend_refresh_token_it_will_not_really_work_4lXUgI5gIdk8T5Rb4Zlx8-Z1rlY-0y4pu5b4lIjh60wQY_g0vkteuQE0Or0cPDbstDnLg8uRpz5dM4GNg7QHYQ' + success, rtn = (True, user.access_token) + user.base_api.access_token = self.user.access_token + self.logger.info(f'Authentcation Success: {success}') + if not success: self.logger.error(f'Authentication error: {str(rtn)}') + return success, rtn + + + def execute_query(self, sql_text:str, sql_type:SXTSqlType = SXTSqlType.DQL, + resources:list = None, user:SXTUser = None, + biscuits:list = None, output_format:SXTOutputFormat = SXTOutputFormat.JSON) -> tuple: + """-------------------- + Execute a query using an authenticated user. If not specified, uses the default user. + + Args: + sql_text (str): SQL query text to execute. Allowed two placeholders: {public_key} which will be replaced with the user.public_key, and {resource} which is replaced with the first element in resource list (resource[0]). + resources (list): (optional) List of Resources ("schema.table_name") in the sql_text. Supplying will optimize performance. If only 1 value, can optionally supply a str. + sql_type (SXTSqlType): (optional) Type of query, DML, DDL, DQL. Supplying will optimize performance. + user (SXTUser): (optional) Authenticated user to use to execute the query. Defaults to default user. + biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. + output_format (SXTOutputFormat): (optional) Output format enum, either JSON or CSV. Defaults to SXTOutputFormat.JSON. + + Returns: + bool: True if success, False if in Error. + list: Rows, either in JSON or CSV format. + + Examples: + >>> from spacenadtime import SpaceAndTime + >>> sxt = SpaceAndTime() + >>> sxt.authenticate() + >>> execute_query('Select 1 as A from SXTDEMO.Singularity') + 1 + + """ + if not user: user = self.user + if not resources: resources = [] + if not biscuits: biscuits = [] + rtn = [] + + try: + resources = resources if type(resources)==list else [str(resources)] + sql_text = self.__replaceall(mainstr=sql_text, replacemap={'resource':resources[0] if resources else [] ,'public_key':user.public_key }) + self.logger.info(f'Executing query: \n{sql_text}') + + if self.network_calls_enabled: + if sql_type == SXTSqlType.DDL : + success, rtn = user.base_api.sql_ddl(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name) + + elif sql_type == SXTSqlType.DML and resources: + success, rtn = user.base_api.sql_dml(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name, resources=resources) + + elif sql_type == SXTSqlType.DQL and resources: + success, rtn = user.base_api.sql_dql(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name, resources=resources) + + else: + success, rtn = user.base_api.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name) + else: + success, rtn = (True, [{'col1':'data', 'col2':'data'},{'col1':'data', 'col2':'data'},{'col1':'data', 'col2':'data'}] ) + + if not success: raise SxTQueryError(f'Query Failed: {str(rtn)}', logger=self.logger) + + except SxTQueryError as ex: + self.logger.error(f'Error in query execution: {ex}') + return False, {'error':f'Error in query execution: {ex}'} + + if output_format == SXTOutputFormat.JSON: return True, rtn + if output_format == SXTOutputFormat.CSV: return True, self.json_to_csv(rtn) + return True, rtn + + @staticmethod + def json_to_csv(list_of_dicts:list) -> list: + """-------------------- + Takes a list of dictionaries (default return from DQL query) and transforms to a list of CSV rows, preceded with a header row. + + Args: + list_of_dicts (list): A list of dictionary items, i.e., rows of JSON columns. + + Returns: + list: A list of CSV strings, i.e., rows of CSV values plus a header row (len(list) will always be N+1) + + Examples: + >>> success, rows = SpaceAndTime.json_to_csv([{"id":1, "val":"A"}, {"id":2, "val":"B"}, {"id":3, "val":"C"}]) + >>> len(rows) + 4 + >>> rows[1] == '"1","A"' + True + + """ + if list_of_dicts == []: return False, [] + rows = [','.join( list(list_of_dicts[0].keys()) )] # headers + for row in list_of_dicts: + rows.append( ','.join([f'"{str(val).replace(chr(34),chr(34)+chr(34))}"' for val in list(row.values())]) ) + return True, rows + + + def __replaceall(self, mainstr:str, replacemap:dict) -> str: + if 'date' not in replacemap.keys(): replacemap['date'] = datetime.now().strftime('%Y%m%d') + if 'time' not in replacemap.keys(): replacemap['time'] = datetime.now().strftime('%H%M%S') + for findname, replaceval in replacemap.items(): + mainstr = mainstr.replace('{'+str(findname)+'}', str(replaceval)) + return mainstr + + + def discovery_get_schemas(self, scope:SXTDiscoveryScope = SXTDiscoveryScope.ALL, + user:SXTUser = None, + return_as:type = list) -> tuple: + """-------------------- + Connects to the Space and Time network and returns all available schemas. + + Args: + scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. + user (SXTUser): (optional) Authenticated User object. Uses default user if omitted. + return_as (type): (optional) Python type to return. Currently supports n, dict, list, str. + + Returns: + object: Return type defined with the return_as feature. + """ + if not user: user = self.user + if not scope: scope = SXTDiscoveryScope.ALL + success, response = user.base_api.discovery_get_schemas(scope=scope.name) + if success and return_as in [list, str]: response = sorted([tbl['schema'] for tbl in response]) + if success and return_as == str: response = ', '.join(response) + if success and return_as not in [json, dict, list, str]: + self.logger.warning('Supplied an unsupported return type, only [json, list, str] currently supported. Defaulting to dict.') + return success, response + + + def discovery_get_tables(self, schema:str, + scope:SXTDiscoveryScope = SXTDiscoveryScope.ALL, + user:SXTUser = None, + search_pattern:str = None, + return_as:type = json) -> tuple: + """-------------------- + Connects to the Space and Time network and returns all available tables within a schema. + + Args: + schema (str): Schema name to search for tables. + scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. + user (SXTUser): (optional) Authenticated User object. Uses default user if omitted. + search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all tables. + return_as (type): (optional) Python type to return. Currently supports n, dict, list, str. + + Returns: + object: Return type defined with the return_as feature. + """ + if not user: user = self.user + if not scope: scope = SXTDiscoveryScope.ALL + if scope == SXTDiscoveryScope.ALL: + self.logger.warning('Warning, scope of ALL requires special admin permissions.') + success, response = user.base_api.discovery_get_tables(scope=scope.name, schema=schema, search_pattern=search_pattern) + if success and return_as in [list, str]: response = sorted([tbl['table'] for tbl in response]) + if success and return_as == str: response = ', '.join(response) + if success and return_as not in [json, dict, list, str]: + self.logger.warning('Supplied an unsupported return type, only [json, list, str] currently supported. Defaulting to dict.') + return success, response + + + + + + + +if __name__ == '__main__': + from pprint import pprint + def randpad(i=6): return str(random.randint(0,999999)).rjust(6,'0') + + if True: + + # BASIC USAGE + sxt = SpaceAndTime() + sxt.authenticate() + + success, rows = sxt.execute_query( + 'select * from POLYGON.BLOCKS limit 5') + pprint( rows ) + + + # bit more complicated: + success, rows = sxt.execute_query(""" + SELECT + substr(time_stamp,1,7) AS YrMth + ,count(*) AS block_count + FROM polygon.blocks + GROUP BY YrMth + ORDER BY 1 desc """ ) + pprint( sxt.json_to_csv(rows) ) + + print( sxt.user ) + + + # discovery calls provide network information + success, schemas = sxt.discovery_get_schemas() + pprint(f'There are {len(schemas)} schemas currently on the network.') + pprint(schemas) + + + # Create a table (with random name) + tableA = SXTTable(name = f"SXTTEMP.MyTestTable_{randpad()}", + new_keypair=True, default_user=sxt.user, logger=sxt.logger, + access_type=SXTTableAccessType.PERMISSSIONED) + tableA.create_ddl = """ + CREATE TABLE {table_name} + ( MyID int + , MyName varchar + , MyDate date + , Primary Key(MyID) + ) {with_statement} + """ + tableA.add_biscuit('read', tableA.PERMISSION.SELECT ) + tableA.add_biscuit('write', tableA.PERMISSION.SELECT, tableA.PERMISSION.INSERT, + tableA.PERMISSION.UPDATE, tableA.PERMISSION.DELETE, + tableA.PERMISSION.MERGE ) + tableA.add_biscuit('admin', tableA.PERMISSION.ALL ) + + tableA.save() # <-- Important! Don't lose your keys, or you lose control of your table + success, results = tableA.create() + + if success: # load some records + + # generate some dummy data + cols = ['MyID','MyName','MyDate'] + data = [[i, chr(64+i), f'2023-09-0{i}'] for i in list(range(1,10))] + + # insert + tableA.insert(columns=cols, data=data) + + # select rows, just for fun + success, rows = tableA.select() + pprint( rows ) + + success, results = tableA.delete(where='MyID=6') + + # should be one less than last time + success, rows = tableA.select() + pprint( rows ) + + + + # emulate starting over, loading from save file + user_selection = tableA.recommended_filename + tableA = None + sxt = None + + # reload from save file: + sxt = SpaceAndTime() + sxt.authenticate() + + tableA = SXTTable(from_file = user_selection, default_user=sxt.user) + pprint( tableA ) + pprint( tableA.select() ) + + + # create a view + viewB = SXTView('SXTTEMP.MyTest_Odds', default_user=tableA.user, + private_key=tableA.private_key, logger=tableA.logger) + viewB.add_biscuit('read', viewB.PERMISSION.SELECT) + viewB.add_biscuit('admin', viewB.PERMISSION.ALL) + viewB.table_biscuit = tableA.get_biscuit('admin') + viewB.create_ddl = """ + CREATE VIEW {view_name} + {with_statement} + AS + SELECT * + FROM """ + tableA.table_name + """ + WHERE MyID in (1,3,5,7,9) """ + + viewB.save() # <-- Important! don't lose keys! + viewB.create() + + # the view will be created immediately, but there may be a small delay in seeing data + # until end of 2023. + + input(""" + Now is your time to pause and play around... + after pressing enter, the script will drop objects + in order to clean up. + + Note, if you wait too long (>25min) the access_token will time-out + and you'll need to tableA.user.authenticate() again. + """) + + viewB.drop() + tableA.drop() + + print( tableA.recommended_filename ) + print( viewB.recommended_filename ) + print( sxt.user.recommended_filename ) + + # Multiple Users, Multiple Tables + suzy = SXTUser('.env', authenticate=True) + + bill = SXTUser() + bill.load('.env') + bill.authenticate() + + print('\nDifferent Logins? ', suzy.access_token != bill.access_token) + + # new user + pat = SXTUser(user_id=f'pat_{randpad()}') + pat.new_keypair() + pat.api_url = suzy.api_url + pat.save() # <-- Important! don't lose keys! + pat.authenticate() + + # suzy to invite pat: + if suzy.user_type in ['owner','admin']: + joincode = suzy.generate_joincode() + success, results = pat.join_subscription(joincode=joincode) + pprint( results ) + + + # emulate starting over + pats_file = pat.recommended_filename + pat = SXTUser(dotenv_file=pats_file) + pat.authenticate() + + pass + \ No newline at end of file diff --git a/src/spaceandtime/sxtbaseapi.py b/src/spaceandtime/sxtbaseapi.py new file mode 100644 index 0000000..0f7a2c5 --- /dev/null +++ b/src/spaceandtime/sxtbaseapi.py @@ -0,0 +1,616 @@ +import requests, logging, json +from pathlib import Path +from .sxtenums import SXTApiCallTypes +from .sxtexceptions import SxTArgumentError +from .sxtbiscuits import SXTBiscuit + + +class SXTBaseAPI(): + api_url = 'https://api.spaceandtime.app' + access_token = '' + logger: logging.Logger + standard_headers = { + "accept": "application/json", + "content-type": "application/json" + } + versions = {} + + + def __init__(self, access_token:str = '', logger:logging.Logger = None) -> None: + if logger: + self.logger = logger + else: + self.logger = logging.getLogger() + self.logger.setLevel(logging.INFO) + if len(self.logger.handlers) == 0: + self.logger.addHandler( logging.StreamHandler() ) + + apiversionfile = Path(Path(__file__).resolve().parent / 'apiversions.json') + self.access_token = access_token + with open(apiversionfile,'r') as fh: + content = fh.read() + self.versions = json.loads(content) + + + def prep_biscuits(self, biscuits=list) -> list: + """-------------------- + Accepts biscuits in various data types, and returns a list of biscuit_tokens as strings (list of str). + Primary use-case is class-internal. + + Args: + biscuits (list | str | SXTBiscuit): biscuit_tokens as a list, str, or SXTBiscuit type. + + Returns: + list: biscuit_tokens as a list. + + Examples: + >>> sxt = SpaceAndTime() + >>> biscuits = sxt.user.base_api.prep_biscuits(['a',['b','c'], 'd']) + >>> biscuits == ['a', 'b', 'c', 'd'] + True + """ + if type(biscuits) == str: + return [biscuits] + elif type(biscuits) == SXTBiscuit: + return [biscuits.biscuit_token] + elif type(biscuits) == list: + rtn=[] + for biscuit in biscuits: + rtn = rtn + self.prep_biscuits(biscuit) + return rtn + else: + return None + + + def prep_sql(self, sql_text:str) -> str: + """------------------- + Cleans and prepares sql_text for transmission and execution on-network. + + Args: + sql_text (str): SQL text to prepare. + + Returns: + sql: slightly modified / cleansed SQL text + + Examples: + >>> api = SXTBaseAPI() + >>> sql = "Select 'complex \nstring ' as A \n \t from \n\t TableName \n Where A=1;" + >>> newsql = api.prep_sql(sql) + >>> newsql == "Select 'complex \nstring ' as A from TableName Where A=1" + True + """ + insinglequote = False + indoublequote = False + rtn = [] + prevchar = '' + for char in list(sql_text.strip()): + + # escape anything in quotes + if char == "'": insinglequote = not insinglequote + elif char == '"': indoublequote = not indoublequote + if insinglequote or indoublequote: + rtn.append(char) + prevchar = '' + continue + + # replace newlines and tabs with spaces + if char in ['\n', '\t']: char = ' ' + + # remove double-spaces + if char == ' ' and prevchar == ' ': continue + + rtn.append(char) + prevchar = char + + # remove ; if last character + if char == ';': rtn = rtn[:-1] + return str(''.join(rtn)).strip() + + + def call_api(self, endpoint: str, + auth_header:bool = True, + request_type:str = SXTApiCallTypes.POST, + header_parms: dict = {}, + data_parms: dict = {}, + query_parms: dict = {}, + path_parms: dict = {} ): + """-------------------- + Generic function to call and return SxT API. + + This is the base api execution function. It can, but is not intended, to be used directly. + Rather, it is wrapped by other api-specific functions, to isolate api call differences + from the actual api execution, which can all be the same. + + Args: + endpoint (str): URL endpoint, after the version. Final structure is: [api_url/version/endpoint] + request_type (SXTApiCallTypes): Type of request. [POST, GET, PUT, DELETE] + auth_header (bool): flag indicator whether to append the Bearer token to the header. + header_parms: (dict): Name/Value pair to add to request header, except for bearer token. {Name: Value} + query_parms: (dict): Name/value pairs to be added to the query string. {Name: Value} + data_parms (dict): Dictionary to be used holistically for --data json object. + path_parms (dict): Pattern to replace placeholders in URL. {Placeholder_in_URL: Replace_Value} + + Results: + bool: Indicating request success + json: Result of the API, expressed as a JSON object + """ + try: + version = self.versions[endpoint] + self.logger.debug(f'API Call started for endpoint: {version}/{endpoint}') + + if request_type not in SXTApiCallTypes: + msg = f'request_type must be of type SXTApiCallTypes, not { type(request_type) }' + raise SxTArgumentError(msg, logger=self.logger) + + # Path parms + for name, value in path_parms.items(): + endpoint = endpoint.replace(name,value) + + # Query parms + if query_parms !={}: + endpoint = f'{endpoint}?' + '&'.join([f'{n}={v}' for n,v in query_parms.items()]) + + # Header parms + headers = {k:v for k,v in self.standard_headers.items()} + if auth_header: headers['authorization'] = f'Bearer {self.access_token}' + headers.update(header_parms) + + # final URL + url = f'{self.api_url}/{version}/{endpoint}' + + match request_type: + case SXTApiCallTypes.POST : callfunc = requests.post + case SXTApiCallTypes.GET : callfunc = requests.get + case SXTApiCallTypes.PUT : callfunc = requests.put + case SXTApiCallTypes.DELETE : callfunc = requests.delete + case _: raise SxTArgumentError('Call type must be SXTApiCallTypes enum.', logger=self.logger) + + response = callfunc(url=url, data=json.dumps(data_parms), headers=headers) + txt = 'response.text not available' + txt = response.text + response.raise_for_status() + + try: + rtn = response.json() + except json.decoder.JSONDecodeError as ex: + rtn = {'status_code':response.status_code, 'text':txt} + + self.logger.debug(f'API call completed for endpoint: "{endpoint}" with result: {txt}') + return True, rtn + + except requests.exceptions.RequestException as ex: + self.logger.error(str(txt)) + return False, {'status':response.status_code, 'error': txt} + except Exception as ex: + self.logger.error(str(txt)) + return False, {'status':response.status_code, 'error': txt} + + + def get_auth_challenge_token(self, user_id:str, prefix:str = None, joincode:str = None): + """-------------------- + (alias) Calls and returns data from API: auth/code, which issues a random challenge token to be signed as part of the authentication workflow. + + Args: + user_id (str): UserID to be authenticated + prefix (str): (optional) The message prefix for signature verification (used for improved front-end UX). + joincode (str): (optional) Joincode if creating a new user within an existing subscription. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + return self.auth_code(user_id, prefix, joincode) + + + def auth_code(self, user_id:str, prefix:str = None, joincode:str = None): + """-------------------- + Calls and returns data from API: auth/code, which issues a random challenge token to be signed as part of the authentication workflow. + + Args: + user_id (str): UserID to be authenticated + prefix (str): (optional) The message prefix for signature verification (used for improved front-end UX). + joincode (str): (optional) Joincode if creating a new user within an existing subscription. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + dataparms = {"userId": user_id} + if prefix: dataparms["prefix"] = prefix + if joincode: dataparms[joincode] = joincode + success, rtn = self.call_api(endpoint = 'auth/code', auth_header = False, data_parms = dataparms) + return success, rtn if success else [rtn] + + + def get_access_token(self, user_id:str, public_key:str, challange_token:str, signed_challange_token:str, scheme:str = "ed25519"): + """-------------------- + (alias) Calls and returns data from API: auth/token, which validates signed challenge token and provides new Access_Token and Refresh_Token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + return self.auth_token(user_id, public_key, challange_token, signed_challange_token, scheme) + + + def auth_token(self, user_id:str, public_key:str, challange_token:str, signed_challange_token:str, scheme:str = "ed25519"): + """-------------------- + Calls and returns data from API: auth/token, which validates signed challenge token and provides new Access_Token and Refresh_Token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + dataparms = { "userId": user_id + ,"signature": signed_challange_token + ,"authCode": challange_token + ,"key": public_key + ,"scheme": scheme} + success, rtn = self.call_api(endpoint='auth/token', auth_header=False, data_parms=dataparms) + return success, rtn if success else [rtn] + + + def token_refresh(self, refresh_token:str): + """-------------------- + Calls and returns data from API: auth/refresh, which accepts a Refresh_Token and provides a new Access_Token and Refresh_Token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + headers = { 'authorization': f'Bearer {refresh_token}' } + success, rtn = self.call_api('auth/refresh', False, header_parms=headers) + return success, rtn if success else [rtn] + + + def auth_logout(self): + """-------------------- + Calls and returns data from API: auth/logout, which invalidates Access_Token and Refresh_Token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, rtn = self.call_api('auth/logout', True) + return success, rtn if success else [rtn] + + + def auth_validtoken(self): + """-------------------- + Calls and returns data from API: auth/validtoken, which returns information on a valid token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, rtn = self.call_api('auth/validtoken', True, SXTApiCallTypes.GET) + return success, rtn if success else [rtn] + + + def auth_idexists(self, user_id:str ): + """-------------------- + Calls and returns data from API: auth/idexists, which returns True if the User_ID supplied exists, False if not. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, rtn = self.call_api(f'auth/idexists/{user_id}', False, SXTApiCallTypes.GET) + return success, rtn if success else [rtn] + + + def auth_keys(self): + """-------------------- + Calls and returns data from API: auth/keys (get), which returns all keys for a valid token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, rtn = self.call_api('auth/keys', True, SXTApiCallTypes.GET) + return success, rtn if success else [rtn] + + + def auth_addkey(self, user_id:str, public_key:str, challange_token:str, signed_challange_token:str, scheme:str = "ed25519"): + """-------------------- + Calls and returns data from API: auth/keys (post), which adds a new key to the valid token. Requires similar challenge/sign/return as authentication. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + dataparms = { "authCode": challange_token + ,"signature": signed_challange_token + ,"key": public_key + ,"scheme": scheme } + success, rtn = self.call_api('auth/keys', True, SXTApiCallTypes.POST, data_parms=dataparms) + return success, rtn if success else [rtn] + + + def auth_addkey_challenge(self): + """-------------------- + Request a challenge token from the Space and Time network, for authentication. + + (alias) Calls and returns data from API: auth/keys (get), which returns all keys for a valid token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + return self.auth_keys_code() + + + def auth_keys_code(self): + """-------------------- + Calls and returns data from API: auth/keys (get), which returns all keys for a valid token. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, rtn = self.call_api('auth/keys/code', True) + return success, rtn if success else [rtn] + + + def sql_exec(self, sql_text:str, biscuits:list = None, app_name:str = None, validate:bool = False): + """-------------------- + Executes a database statement/query of arbitrary type (DML, DDL, DQL), and returns a status or data. + + Calls and returns data from API: sql, which runs arbitrary SQL and returns records (if any). + This api call undergoes one additional SQL parse step to interrogate the type and + affected tables / views, so is slightly less performant (by 50-100ms) than the type-specific + api calls, sql_ddl, sql_dml, sql_dql. Normal human interaction will not be noticed, but + if tuning for high-performnace applications, consider using the correct typed call. + + Args: + sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. + biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. + app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. + validate (bool): (optional) Perform an additional SQL validation in-parser, before database submission. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + headers = { 'originApp': app_name } if app_name else {} + sql_text = self.prep_sql(sql_text=sql_text) + biscuit_tokens = self.prep_biscuits(biscuits) + if type(biscuit_tokens) != list: raise SxTArgumentError("sql_all requires parameter 'biscuits' to be a list of biscuit_tokens or SXTBiscuit objects.", logging= self.logging) + dataparms = {"sqlText": sql_text + ,"biscuits": biscuit_tokens + ,"validate": str(validate).lower() } + success, rtn = self.call_api('sql', True, header_parms=headers, data_parms=dataparms) + return success, rtn if success else [rtn] + + + def sql_ddl(self, sql_text:str, biscuits:list = None, app_name:str = None): + """-------------------- + Executes a database DDL statement, and returns status. + + Calls and returns data from API: sql/ddl, which runs arbitrary DDL for creating resources. + This will be slightly more performant than the generic sql_exec function, but requires a resource name. + Biscuits are always required for DDL. + + Args: + sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. + biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. + app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + headers = { 'originApp': app_name } if app_name else {} + sql_text = self.prep_sql(sql_text=sql_text) + biscuit_tokens = self.prep_biscuits(biscuits) + if biscuit_tokens==[]: raise SxTArgumentError("sql_all requires parameter 'biscuits' to be a single or list of string biscuit_tokens or SXTBiscuit objects.", logging= self.logging) + dataparms = {"sqlText": sql_text + ,"biscuits": biscuit_tokens } + # ,"resources": [r for r in resources] } + success, rtn = self.call_api('sql/ddl', True, header_parms=headers, data_parms=dataparms) + return success, rtn if success else [rtn] + + + def sql_dml(self, sql_text:str, resources:list, biscuits:list = None, app_name:str = None): + """-------------------- + Executes a database DML statement, and returns status. + + Calls and returns data from API: sql/dml, which runs arbitrary DML for manipulating data. + This will be slightly more performant than the generic sql_exec function, but requires a resource name. + Biscuits are required for any non-public-write tables. + + Args: + sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. + resources (list): List of Resources ("schema.table_name") in the sql_text. + biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. + app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + if type(resources) != list: resources = [resources] + headers = { 'originApp': app_name } if app_name else {} + sql_text = self.prep_sql(sql_text=sql_text) + biscuit_tokens = self.prep_biscuits(biscuits) + if type(biscuit_tokens) != list: raise SxTArgumentError("sql_all requires parameter 'biscuits' to be a list of biscuit_tokens or SXTBiscuit objects.", logging= self.logging) + headers = { 'originApp': app_name } if app_name else {} + dataparms = {"sqlText": sql_text + ,"biscuits": biscuit_tokens + ,"resources": [r for r in resources] } + success, rtn = self.call_api('sql/dml', True, header_parms=headers, data_parms=dataparms) + return success, rtn if success else [rtn] + + + def sql_dql(self, sql_text:str, resources:list, biscuits:list = None, app_name:str = None): + """-------------------- + Executes a database DQL / SQL query, and returns a dataset as a list of dictionaries. + + Calls and returns data from API: sql/dql, which runs arbitrary SELECT statements that return data. + This will be slightly more performant than the generic sql_exec function, but requires a resource name. + Biscuits are required for any non-public tables. + + Args: + sql_text (str): SQL query text to execute. Note, there is NO placeholder replacement. + resources (list): List of Resources ("schema.table_name") in the sql_text. + biscuits (list): (optional) List of biscuit tokens for permissioned tables. If only querying public tables, this is not needed. + app_name (str): (optional) Name that will appear in querylog, used for bucketing workload. + + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + if type(resources) != list: resources = [resources] + headers = { 'originApp': app_name } if app_name else {} + sql_text = self.prep_sql(sql_text=sql_text) + biscuit_tokens = self.prep_biscuits(biscuits) + if type(biscuit_tokens) != list: raise SxTArgumentError("sql_all requires parameter 'biscuits' to be a list of biscuit_tokens or SXTBiscuit objects.", logging= self.logging) + dataparms = {"sqlText": sql_text + ,"biscuits": biscuit_tokens + ,"resources": [r for r in resources] } + success, rtn = self.call_api('sql/dql', True, header_parms=headers, data_parms=dataparms) + return success, rtn if success else [rtn] + + + def discovery_get_schemas(self, scope:str = 'ALL'): + """-------------------- + Connects to the Space and Time network and returns all available schemas. + + Calls and returns data from API: discover/schema + + Args: + scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, rtn = self.call_api('discover/schema',True, SXTApiCallTypes.GET, query_parms={'scope':scope}) + return success, (rtn if success else [rtn]) + + + def discovery_get_tables(self, schema:str = 'ETHEREUM', scope:str = 'ALL', search_pattern:str = None): + """-------------------- + Connects to the Space and Time network and returns all available tables within a schema. + + Calls and returns data from API: discover/table + + Args: + schema (str): Schema name to search for tables. + scope (SXTDiscoveryScope): (optional) Scope of objects to return: All, Public, Subscription, or Private. Defaults to SXTDiscoveryScope.ALL. + search_pattern (str): (optional) Tablename pattern to match for inclusion into result set. Defaults to None / all tables. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + version = 'v2' if 'discover/table' not in list(self.versions.keys()) else self.versions['discover/table'] + schema_or_namespace = 'namespace' if version=='v1' else 'schema' + query_parms = {'scope':scope.upper(), schema_or_namespace:schema.upper()} + if version != 'v1' and search_pattern: query_parms['searchPattern'] = search_pattern + success, rtn = self.call_api('discover/table',True, SXTApiCallTypes.GET, query_parms=query_parms) + return success, (rtn if success else [rtn]) + + + def subscription_get_info(self): + """-------------------- + Retrieves information on the authenticated user's subscription from the Space and Time network. + + Calls and returns data from API: subscription + + Args: + None + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + endpoint = 'subscription' + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.GET ) + return success, (rtn if success else [rtn]) + + + def subscription_get_users(self): + """-------------------- + Retrieves information on all users of a subscription from the Space and Time network. May be restricted to Admin or Owners. + + Calls and returns data from API: subscription/users + + Args: + None + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + endpoint = 'subscription/users' + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.GET ) + return success, (rtn if success else [rtn]) + + + def subscription_invite_user(self, role:str = 'member'): + """-------------------- + Creates a subcription invite code (aka joincode). Can join as member, admin, owner. + + Calls and returns data from API: subscription/invite. + Allows an Admin or Owner to generate a joincode for another user, who (after authenticating) + can consume the code and join the subcription at the specified level. + The code is only valid for 24 hours, and assigned role cannot be greater than the creator + (i.e., an Admin cannot generate an Owner code). + + Args: + role (str): Role level to assign the new user. Can be member, admin, or owner. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + endpoint = 'subscription/invite' + role = role.upper().strip() + if role not in ['MEMBER','ADMIN','OWNER']: + return False, {'error':'Invites must be either member, admin, or owner. Permissions cannot exceed the invitor.'} + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, + query_parms={'role':role} ) + return success, (rtn if success else [rtn]) + + + def subscription_join(self, joincode:str): + """-------------------- + Allows the authenticated user to join a subscription by using a valid joincode. + + Calls and returns data from API: subscription/invite/{joinCode}. + Note, joincodes are only valid for 24 hours. + + Args: + joincode (str): Code created by an admin to allow an authenticated user to join their subscription. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + endpoint = 'subscription/invite/{joinCode}' + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, + path_parms= {'{joinCode}': joincode} ) + return success, (rtn if success else [rtn]) + + + +if __name__ == '__main__': + + token = 'eyJ0eXBlIjoiYWNjZXNzIiwia2lkIjoiZTUxNDVkYmQtZGNmYi00ZjI4LTg3NzItZjVmNjNlMzcwM2JlIiwiYWxnIjoiRVMyNTYifQ.eyJpYXQiOjE2OTU5MTQxMjgsIm5iZiI6MTY5NTkxNDEyOCwiZXhwIjoxNjk1OTE1NjI4LCJ0eXBlIjoiYWNjZXNzIiwidXNlciI6InN0ZXBoZW4iLCJzdWJzY3JpcHRpb24iOiIzMWNiMGI0Yi0xMjZlLTRlM2MtYTdhMS1lNWRmNDc4YTBjMDUiLCJzZXNzaW9uIjoiNTg2OTQyOTgzMjc2OTkyNzI5MDViMDQyIiwic3NuX2V4cCI6MTY5NjAwMDUyODQ2OSwiaXRlcmF0aW9uIjoiZDc0M2Y1YjRkNTkyYzdmNjU4ZDA5ZmM2In0.lKjO0CbQ4k8hAEPsbs9nL1qXGzm01ZfJEF_l8NiRQRbTBkrdPV53H8lzdJsHTpGdcgSvsgbwpxzKvUnqyl1cAg' + api = SXTBaseAPI(token) + + print( api.subscription_get_info() ) + success, users = api.subscription_get_users() + + success, response = api.subscription_invite_user(role='owner') + joincode = response['text'] + + print( api.subscription_join(joincode=joincode) ) + + pass \ No newline at end of file diff --git a/src/spaceandtime/sxtbiscuits.py b/src/spaceandtime/sxtbiscuits.py new file mode 100644 index 0000000..4b7433a --- /dev/null +++ b/src/spaceandtime/sxtbiscuits.py @@ -0,0 +1,549 @@ +import logging, json +from pathlib import Path +from datetime import datetime, timedelta +from biscuit_auth import KeyPair, PrivateKey, PublicKey, Authorizer, Biscuit, BiscuitBuilder, BlockBuilder, Rule, DataLogError +from .sxtexceptions import SxTArgumentError, SxTFileContentError, SxTBiscuitError, SxTKeyEncodingError +from .sxtenums import SXTPermission, SXTKeyEncodings +from .sxtkeymanager import SXTKeyManager + + + +class SXTBiscuit(): + """Definition of a single biscuit.""" + + logger: logging.Logger = None + domain: str = 'sxt' + name: str = 'biscuit_name' + key_manager: SXTKeyManager = None + GRANT = SXTPermission + ENCODINGS = SXTKeyEncodings + __cap:dict = {'schema.resource':['permission1', 'permission2']} + __usr:list = ['userA','userB'] + __time:list = [ [datetime.now(), datetime.now() + timedelta(30)] ] + __bt: str = '' + __btchanged:bool = True + __lastresource: str = '' + __manualtoken:bool = False + __parentbiscuit__:bool = False + + def __init__(self, name:str = '', private_key: str = None, new_keypair: bool = False, from_file: Path = None, logger:logging.Logger = None, biscuit_token:str = None) -> None: + if logger: + self.logger = logger + else: + self.logger = logging.getLogger() + self.logger.setLevel(logging.INFO) + if len(self.logger.handlers) == 0: + self.logger.addHandler( logging.StreamHandler() ) + self.logger.info('-'*30 + '\nNew SXT Biscuit initiated') + self.key_manager = SXTKeyManager(logger=self.logger, encoding=SXTKeyEncodings.BASE64) + if new_keypair: self.key_manager.new_keypair() + if private_key: self.private_key = private_key + self.__cap = {} + self.__usr = [] + self.__time = [] + if name: self.name = name + if from_file and Path(from_file).exists: self.load(from_file) + if biscuit_token: + self.__manualtoken = True + self.__bt = biscuit_token + self.logger.warning('manual biscuit token accepted as-is, not calculated or verified.') + + + def __str__(self): + return '\n'.join([f"{str(n).rjust(25)}: {v}" for n,v in dict(self.to_json(True, True)).items()]) + + def __repr__(self): + return '\n'.join([f"{str(n).rjust(25)}: {v}" for n,v in dict(self.to_json(False, True)).items()]) + + def __len__(self): + return 1 + + @property + def biscuit_text(self): + bis = sorted([ f'check if {self.domain}:user("{u}");' for u in self.__usr]) + bis.extend( sorted( [ f'check if time($time), $time <= {t[0].strftime("%Y-%m-%dT%H:%M:%SZ")}, $time >= {t[1].strftime("%Y-%m-%dT%H:%M:%SZ")};' for t in self.__time] )) + for resource, permissions in self.__cap.items(): + for permission in sorted([p.value for p in permissions]): + bis.append(f'{self.domain}:capability("{permission}", "{str(resource).lower()}");') + return '\n'.join(bis) + + + + @property + def biscuit_token(self) ->str: + if self.__manualtoken: return self.__bt + if not self.private_key or not self.biscuit_text: + self.__bt = '' + return '' + if self.__btchanged: self.__bt = self.regenerate_biscuit_token() + return self.__bt + + @property + def biscuit_json(self) -> dict: + return self.__cap + + @property + def private_key(self) ->str : + return self.key_manager.private_key + @private_key.setter + def private_key(self, value): + self.key_manager.private_key = value + self.__btchanged = True + + @property + def public_key(self) ->str : + return self.key_manager.public_key + @public_key.setter + def public_key(self, value): + self.key_manager.public_key = value + self.__btchanged = True + + @property + def encoding(self) ->str : + return self.key_manager.encoding + @encoding.setter + def encoding(self, value): + self.key_manager.encoding = value + + + def new_keypair(self) -> dict: + return self.key_manager.new_keypair() + + + def regenerate_biscuit_token(self) -> dict: + """-------------------- + Regenerates the biscuit_token from class.biscuit_text and class.private_key. + + For object consistency, this only leverages the class objects, so there are no arguments. + To build the biscuit_text, add_capability(), add_user_check(), or add_time_check, or if you want + to import an existing datalog file, you can load capabilities_from_text(). This function will + error without a valid private_key and biscuit_text. + + Args: + None + + Returns: + str: biscuit_token in base64 format. + """ + self.__btchanged = True + + if not self.private_key: + raise SxTArgumentError("Private Key is required to create a biscuit", logger=self.logger) + + biscuit_text = self.biscuit_text + if not biscuit_text: + raise SxTArgumentError('Biscuit_Text is required to create a biscuit. Try to add_capability() and inspect biscuit_text to verify.', logger=self.logger) + + try: + private_key_obj = PrivateKey.from_hex(self.key_manager.private_key_to(SXTKeyEncodings.HEX)) + biscuit = BiscuitBuilder(self.biscuit_text).build(private_key_obj) + self.__btchanged = False + return biscuit.to_base64() + except DataLogError as ex: + errmsg = ex + raise SxTBiscuitError(errmsg, logger=self.logger) + + + def validate_biscuit(self, biscuit_base64:str, public_key = None) -> str: + if not public_key: public_key = self.public_key + public_key = self.convert_key(public_key, self.get_encoding_type(public_key), SXTKeyEncodings.HEX ) + try: + return Biscuit.from_base64( data=biscuit_base64, root=PublicKey.from_hex( public_key )) + except Exception as ex: + self.logger.error(ex) + raise SxTBiscuitError('Biscuit not validated with Public Key', logger=self.logger) + + + + def add_time_check(self, start_datetime:datetime = None, end_datetime:datetime = None): + """-------------------- + Uniquiely adds a valid time window to the biscuit, outside of which the biscuit is invalid. + + Args: + start_datetime (datetime): Beginning of valid time window. If omitted, defaults to now() + end_datetime (datetime): End of valid time window. If omitted, defaults to 90 days in the future. + + Returns: + int: Number of checks added (1 or 0) + + Examples: + >>> from datetime import datetime, timedelta + >>> bb = SXTBiscuit() + >>> bb.add_capability("Schema.TableA", bb.GRANT.SELECT) + 1 + >>> bb.add_time_check( datetime.now(), datetime.now() + timedelta(30) ) + 1 + >>> bb.add_time_check( datetime.now(), datetime.now() + timedelta(30) ) + 0 + """ + self.__btchanged = True + + if not start_datetime: start_datetime = datetime.now() + if not end_datetime: end_datetime = start_datetime + timedelta(90) + + final_times = [(start_datetime, end_datetime)] + + beginning_user_count = len(self.__time) + final_times.extend( self.__time ) + final_times = list(set(final_times)) + ending_user_count = len(final_times) + added_count = ending_user_count - beginning_user_count + dup_count = 1 - added_count + + self.__time = [u for u in final_times] + + self.logger.info(f'Added {added_count} time frame{", " if added_count==1 else "s,"} from total 1 submitted ({dup_count} duplicate{"" if dup_count==1 else "s"})') + return added_count + + + def add_user_check(self, *users): + """-------------------- + Uniquely adds a list of user checks to a biscuit, allowing only those users access. + + If a check is added, then the biscuit is only valid if all checks are satisfied. + + Args: + users (*): Any number of UserIDs, as strings or lists of strings, to enable for the biscuit. + + Returns: + int: Number of checks added (excluding duplicates) + + Examples: + >>> bb = SXTBiscuit() + >>> bb.add_capability("Schema.TableA", bb.GRANT.SELECT) + 1 + >>> bb.add_user_check("some_username") + 1 + >>> bb.add_user_check("some_other_username") + 1 + >>> bb.add_user_check("some_other_username") + 0 + """ + self.__btchanged = True + + final_users = [] + [final_users.extend(p) for p in users if type(p) == list ] + [final_users.append(p) for p in users if type(p) == str] + + if len([p for p in users if type(p) != list and type(p) != str ]) != 0: + msg = 'Can only add UserIDs as strings or list of strings.' + self.logger.error(msg) + raise KeyError(msg) + + beginning_user_count = len(self.__usr) + submitted_count = len(final_users) + final_users = list(set(final_users)) + unique_submitted_count = len(final_users) + final_users.extend( self.__usr ) + final_users = list(set(final_users)) + ending_user_count = len(final_users) + added_count = ending_user_count - beginning_user_count + dup_count = submitted_count - added_count + + self.__usr = [u for u in final_users] + + self.logger.info(f'Added {added_count} user{", " if added_count==1 else "s,"} from total {submitted_count} submitted ({dup_count} duplicate{"" if dup_count==1 else "s"})') + return added_count + + + def add_capability(self, resource:str, *permissions): + """-------------------- + Uniquely adds a capability to the existing biscuit structure. + + Args: + resource (str): Resource (Schema.Resource) to which permissions are applied. + permission (*): Any number of SXTPermission enums to GRANT to the resource. + + Returns: + int: Number of items added (excluding duplicates) + + Examples: + >>> bb = SXTBiscuit() + >>> bb.add_capability("Schema.TableA", bb.GRANT.SELECT) + 1 + >>> bb.add_capability("Schema.TableA", bb.GRANT.INSERT) + 1 + >>> bb.add_capability("Schema.TableA", bb.GRANT.INSERT) + 0 + """ + self.__btchanged = True + self.__lastresource = resource + if resource not in self.__cap: self.__cap[resource] = [] + + if 'ALL' in self.__cap[resource] or '*' in self.__cap[resource]: + self.logger.warning('Cannot add other permissions to a biscuit containing ALL permissions. Request disregarded.') + return self.__isall__(resource) + + final_permissions = [] + [final_permissions.extend(p) for p in permissions if type(p) == list ] + [final_permissions.append(p) for p in permissions if type(p) == SXTPermission] + + if len([p for p in permissions if type(p) != list and type(p) != SXTPermission ]) != 0: + msg = 'Can only add SXTPermission (GRANT) data type to a biscuit.' + self.logger.error(msg) + raise KeyError(msg) + + beginning_resource_count = len(self.__cap[resource]) + submitted_count = len(final_permissions) + final_permissions = list(set(final_permissions)) + unique_submitted_count = len(final_permissions) + final_permissions.extend( self.__cap[resource] ) + final_permissions = list(set(final_permissions)) + ending_resource_count = len(final_permissions) + added_count = ending_resource_count - beginning_resource_count + dup_count = submitted_count - added_count + + if self.GRANT.ALL in final_permissions: + self.__cap[resource] = [self.GRANT.ALL] + self.logger.info(f'Added ALL permissions, replacing a total of {ending_resource_count - 1} other permissions.') + return 1 # permissions added + + self.__cap[resource] = final_permissions + + self.logger.info(f'Added {added_count} permission{", " if added_count==1 else "s,"} from total {submitted_count} submitted ({dup_count} duplicate{"" if dup_count==1 else "s"})') + return added_count + + + def capabilities_from_text(self, biscuit_text:str) -> None: + """-------------------- + Loads text into biscuit capabilities, for example, loading a datalog file directly. + + Args: + biscuit_text (str): Text to compile into capabilities. + + Results: + str: biscuit_text that has been digested and re-processed. + + """ + self.__btchanged = True + biscuit_lines = str(biscuit_text).strip().split('\n') + caps = {} + self.logger.debug(f'Translating supplied text into biscuit capabilities...') + for line in biscuit_lines: + if line.strip().startswith(f'{self.domain}:capability'): + c = line.split('"') + if len(c) <5: + raise SxTArgumentError('biscuit_text capabilities must have format domain:capability("PERMISSION", "RESOURCE");') + p = c[1] # permission + r = c[3] # resource + if r not in caps: caps[r] = [] + caps[r].append(p) + for r in list(caps.keys()): + caps[r] = list(set(caps[r])) + self.__cap = caps + + self.logger.debug(f'Successfully translated biscuit_text to biscuit capabilities objects.') + return None + + + def load_from_token(self, biscuit_token:str) -> None: + self.__btchanged = True + raise NotImplementedError('Not implemented yet. Please check back later.') + + + def clear_biscuit(self): + """Clears all existing capabilities from the biscuit structure""" + self.__cap = {} + self.__usr = [] + self.__time = [] + self.__bt = "" + self.__btchanged = True + self.logger.debug('Clearing all biscuit definitions') + return None + + + def to_json(self, mask_private_key:bool = True, add_tabs_to_biscuit_text:bool = False): + """Exports content of biscuit to a json format. WARNING, this can include private key.""" + tab = '\t' if add_tabs_to_biscuit_text else '' + rtn = { 'private_key': getattr(self, 'private_key')[:6]+'...' if mask_private_key else getattr(self, 'private_key') + ,'public_key' : getattr(self, 'public_key' ) + ,'biscuit_capabilities' : self.__cap + ,'biscuit_token': self.biscuit_token + ,'biscuit_text': f'\n{tab}' + str(self.biscuit_text).replace('\n',f'\n{tab}') + } + self.logger.debug(f'Translating object data to json') + return dict(rtn) + + + def save(self, filepath: Path = 'biscuits/biscuit_{resource}_{date}_{time}.json', overwrite:bool = False, resource:str = None) -> Path: + """-------------------- + Saves biscuit information to a json file. + + The filepath will accept three different placholder texts: {resource}, {date}, and {time}. + This allows caller to easily create dynamically named biscuit files, reducing the likelyhood of + overwriting biscuit files and thus losing keys. It is best practice to leave overwrite to False + and use placeholders to save different files, removing older save files only after validation + the keys are not needed anymore. + + Args: + filepath (Path): Full file path which to save, with placeholders allowed. + overwrite (bool): If True, will overwrite file if exists + resource (str): Optional resource name for placeholder in filepath. Defaults to last resource set in add_capability(). + + Results: + Path: Same as filepath, if successful + """ + # TODO: add string.replace for resource, date, time + filepath = Path(filepath).resolve() + # do placeholder replacements + if not resource: resource = self.__lastresource + date = datetime.now().strftime('%Y%m%d') + time = datetime.now().strftime('%H%M%S') + filepath = Path(str(filepath).replace('{resource}', resource).replace('{date}',date).replace('{time}',time)) + if filepath.exists() and not overwrite: + raise FileExistsError(f'{filepath} already exists. Set overwrite = True to overwrite automatically.') + + filepath.parent.mkdir(parents=True, exist_ok=True) + self.logger.debug(f'Opening file to write: {filepath}...') + with open(filepath, 'w') as fh: + fh.write( json.dumps(self.to_json(False)).replace('\t','') ) + self.logger.debug(f'Data written to file.') + return filepath + + + def load(self, filepath: Path, resource:str = None, date:str = None, time:str = None) -> dict: + """-------------------- + Loads a biscuit from correctly formated JSON file. + + The filepath will accept three different placholder texts: {resource}, {date}, and {time}. + This allows caller to easily create (and load) dynamically named biscuit files, reducing the + likelyhood of overwriting biscuit files and thus losing keys. It is best practice to leave + overwrite to False and use placeholders to save different files, removing older save files + only after validation the keys are not needed anymore. + + Args: + filepath (Path): Full file path which to load from + resource (str): Optional resource name for placeholder in filepath. Defaults to last resource set in add_capability(). + date (str): Optional integer-only date (yyyymmdd) for placeholder in file path. Defaults to current date. + time (str): Optional integer-only time (hhmmss) for placeholder in file path. Defaults to current time. + + Results: + Path: Same as filepath, if successful + """ + self.logger.info('Attempting to load biscuit definition from file...') + filepath = Path(filepath).resolve() + # do placeholder replacements + if not resource: resource = self.__lastresource + if not date: date = datetime.now().strftime('%Y%m%d') + if not time: time = datetime.now().strftime('%H%M%S') + filepath = Path(str(filepath).replace('{resource}', resource).replace('{date}',date).replace('{time}',time)) + # TODO: allow option for 'Most Recent" for date and time, which can look thru + # the parent directory and find the file with the most recent {date} / {time}. + # This will hopefully promote behavior of keeping history of keys, in case + # they're needed + + if not filepath.exists: + raise FileNotFoundError(f'{filepath} not found.') + try: + self.logger.debug(f'Opening file: {filepath}...') + with open(filepath, 'r') as fh: + content = json.loads(fh.read()) + if 'private_key' not in content or 'biscuit_text' not in content: + raise SxTFileContentError + except (SxTFileContentError, json.JSONDecodeError): + self.logger.error(f'File not loaded due to missing, malformed content, or simply unable to load JSON: \n{filepath}') + return None + try: + self.__btchanged = True + new_key_encoding = self.key_manager.get_encoding_type(content['private_key']) + new_private_key = self.key_manager.convert_key(content['private_key'], new_key_encoding, SXTKeyEncodings.BYTES) + except SxTArgumentError: + return None + + # Assign last, after all validation. public_key, biscuit_text, biscuit_token all recalculate automatically. + self.logger.info(f'File opened and parsed, loading data into current object.') + self.capabilities_from_text(content['biscuit_text']) + self.private_key = new_private_key + return content + + + + + + +if __name__ == '__main__': + + print('\n', '-=-='*10, '\n' ) + + # BASIC USAGE + bis = SXTBiscuit(name='my first biscuit', new_keypair=True) + bis.add_capability('schema.SomeTable', bis.GRANT.SELECT, bis.GRANT.INSERT, bis.GRANT.INSERT, bis.GRANT.UPDATE, [bis.GRANT.CREATE, bis.GRANT.DROP ]) + bis.add_capability('schema.SomeTable', bis.GRANT.ALTER, [bis.GRANT.CREATE, bis.GRANT.DROP ]) + bis.add_capability('schema.SomeTable', bis.GRANT.SELECT) # deduped + bis.add_capability('schema.AnotherTable', bis.GRANT.SELECT) + bis.add_user_check('test_userA') + bis.add_user_check('test_userB') + bis.add_user_check('test_userB') # deduped + starttime = datetime.now() + enddtime = starttime + timedelta(365) + bis.add_time_check(starttime, enddtime) + bis.add_time_check(starttime, enddtime) # deduped + print( bis.biscuit_text ) + print( bis.biscuit_token ) + # check out https://www.biscuitsec.org/ for external validation. + + + # Permissions can be supplied as individual items, a list of items, or both + bis.clear_biscuit() + bis.add_capability('schema.SomeTable', bis.GRANT.SELECT, [bis.GRANT.UPDATE, bis.GRANT.INSERT, bis.GRANT.DELETE], bis.GRANT.MERGE) + + # Permissions will also deduplicate themselves + bis.add_capability('schema.SomeTable', bis.GRANT.SELECT, [bis.GRANT.CREATE, bis.GRANT.INSERT, bis.GRANT.DROP], bis.GRANT.ALTER) + bis.add_capability('schema.SomeTable', bis.GRANT.MERGE, bis.GRANT.DELETE, bis.GRANT.SELECT, bis.GRANT.CREATE) + print( bis ) + + # Resources with no permissions never make it into the biscuit + bis.clear_biscuit() + bis.add_capability('schema.NoPermissions') + print( f'-->{bis.biscuit_text}<--' ) + print( f'-->{bis.biscuit_token}<--' ) + + # You can add ALL permissions at once + bis.clear_biscuit() + bis.add_capability('schema.SomeTable', bis.GRANT.ALL, bis.GRANT.SELECT ) + print( bis ) + + # To build a "wildcard" biscuit (although not recommended beyond testing) + bis.clear_biscuit() + bis.add_capability('*', bis.GRANT.ALL) + print( bis ) + + # Note, assigning ALL to permissions will remove all other permissions + bis.clear_biscuit() + bis.add_capability('*', bis.GRANT.SELECT, bis.GRANT.INSERT, bis.GRANT.DELETE) + bis.add_capability('*', bis.GRANT.ALL) + print( bis ) + + # ALL biscuits will also prevent assignment of other permissions - see WARNING + bis.add_capability('*', bis.GRANT.SELECT) + print( bis ) + + + # Printing biscuit object as string (__str__) obscures the private key. + # to print the full private key, print the representation instead. + print( repr( bis ) ) + + + # Save biscuit information to disk, so keys are not lost + bis.clear_biscuit() + bis.add_capability('schema.SomeTable', bis.GRANT.SELECT, [bis.GRANT.UPDATE, bis.GRANT.INSERT, bis.GRANT.DELETE], bis.GRANT.MERGE) + save_file = './biscuits/biscuit_{resource}_{date}.json' + print( bis.save(save_file, overwrite = True) ) + + # load new biscuit object from saved json + bis2 = SXTBiscuit(logger=bis.logger, from_file = './biscuits/biscuit_schema.SomeTable_{date}.json') + print( bis2 ) + + # or + bis3 = SXTBiscuit(logger=bis.logger) + bis3.load( './biscuits/biscuit_{resource}_{date}.json', resource='schema.SomeTable' ) + print( bis3 ) + + # Practically speaking, you'll want to guard against losing keys. + # Using the default filename will help. + bis3.save(resource='schema.SomeTable') + + + # Return a json object with information + print( bis.to_json() ) + pass diff --git a/src/spaceandtime/sxtenums.py b/src/spaceandtime/sxtenums.py new file mode 100644 index 0000000..4537157 --- /dev/null +++ b/src/spaceandtime/sxtenums.py @@ -0,0 +1,76 @@ +from enum import Enum + +class SXTPermission(Enum): + SELECT = 'dql_select' + INSERT = 'dml_insert' + UPDATE = 'dml_update' + DELETE = 'dml_delete' + MERGE = 'dml_merge' + CREATE = 'ddl_create' + ALTER = 'ddl_alter' + DROP = 'ddl_drop' + ALL = '*' + def __str__(self) -> str: + return super().__str__() + + +class SXTKeyEncodings(Enum): + HEX = 'hex' + BASE64 = 'base64' + BYTES = 'bytes' + def __str__(self) -> str: + return super().__str__() + + +class SXTApiCallTypes(Enum): + POST = 'post' + GET = 'get' + PUT = 'put' + DELETE = 'delete' + def __str__(self) -> str: + return super().__str__() + + +class SXTSqlType(Enum): + DDL = 'ddl' + DML = 'dml' + DQL = 'dql' + def __str__(self) -> str: + return super().__str__() + + +class SXTOutputFormat(Enum): + JSON = 'json' + CSV = 'csv' + def __str__(self) -> str: + return super().__str__() + + +class SXTTableAccessType(Enum): + PERMISSSIONED = 'permissioned' + PUBLIC_READ = 'public_read' + PUBLIC_APPEND = 'public_append' + PUBLIC_WRITE = 'public_write' + def __str__(self) -> str: + return super().__str__() + + +class SXTResourceType(Enum): + UNDEFINED = 'undefined' + TABLE = 'table_name' + VIEW = 'view_name' + MATERIALIZED_VIEW = 'matview_name' + PARAMETERIZED_VIEW = 'parmview_name' + KAFKA_STREAM = 'kafka_name' + def __str__(self) -> str: + return super().__str__() + + +class SXTDiscoveryScope(Enum): + PRIVATE = 'private' + SUBSCRIPTION = 'subscription' + PUBLIC = 'public' + ALL = 'all' + def __str__(self) -> str: + return super().__str__() + diff --git a/src/spaceandtime/sxtexceptions.py b/src/spaceandtime/sxtexceptions.py new file mode 100644 index 0000000..c4fc88a --- /dev/null +++ b/src/spaceandtime/sxtexceptions.py @@ -0,0 +1,42 @@ +import logging + +def log_if_logger(*args, **kwargs) -> None: + msg = '' + if 'logger' in kwargs: + if 'message' in kwargs: + msg = kwargs['message'] + else: + if len(args) >=1: msg = str(args[0]) + logging.Logger(kwargs['logger']).error(msg) + return None + + +class SxTBiscuitError(Exception): + def __init__(self, *args: object, **kwargs) -> None: + log_if_logger(*args, **kwargs) + super().__init__(*args) + +class SxTKeyEncodingError(Exception): + def __init__(self, *args: object, **kwargs) -> None: + log_if_logger(*args, **kwargs) + super().__init__(*args) + +class SxTArgumentError(Exception): + def __init__(self, *args: object, **kwargs) -> None: + log_if_logger(*args, **kwargs) + super().__init__(*args) + +class SxTFileContentError(Exception): + def __init__(self, *args: object, **kwargs) -> None: + log_if_logger(*args, **kwargs) + super().__init__(*args) + +class SxTQueryError(Exception): + def __init__(self, *args: object, **kwargs) -> None: + log_if_logger(*args, **kwargs) + super().__init__(*args) + +class SxTAuthenticationError(Exception): + def __init__(self, *args: object, **kwargs) -> None: + log_if_logger(*args, **kwargs) + super().__init__(*args) diff --git a/src/spaceandtime/sxtkeymanager.py b/src/spaceandtime/sxtkeymanager.py new file mode 100644 index 0000000..cad1ae0 --- /dev/null +++ b/src/spaceandtime/sxtkeymanager.py @@ -0,0 +1,323 @@ +import logging, base64 +from pathlib import Path +import nacl.signing +from biscuit_auth import KeyPair, PrivateKey +from .sxtexceptions import SxTKeyEncodingError, SxTArgumentError, SxTBiscuitError +from .sxtenums import SXTPermission, SXTKeyEncodings + + + +#### +#### SXT KEY MANAGER +#### +class SXTKeyManager(): + """Class to manage creation and maintenance of keys and biscuits.""" + + biscuits:list = [] + logger:logging.Logger = None + warning_for_biscuit_length = 1800 + keychange_callback_func_list = [] + __pv:bytes = bytes(''.encode()) + __pb:bytes = bytes(''.encode()) + __en:SXTKeyEncodings = SXTKeyEncodings.BASE64 + ENCODINGS = SXTKeyEncodings + + + def __init__(self, private_key:str = None, new_keypair: bool = False, encoding:SXTKeyEncodings = None, keychange_callback_func = None, logger:logging.Logger = None) -> None: + """Class to manage creation and maintenance of keys and biscuits.""" + if logger: + self.logger = logger + else: + self.logger = logging.getLogger() + self.logger.setLevel(logging.INFO) + if len(self.logger.handlers) == 0: + self.logger.addHandler( logging.StreamHandler() ) + self.logger.info('new SXT KeyManager initiated') + self.keychange_callback_func_list = [] + if keychange_callback_func: self.add_keychange_callback(keychange_callback_func) + + if encoding: self.encoding = encoding + if new_keypair: + self.new_keypair() + return None + if private_key: self.private_key = private_key + return None + + + def __str__(self): + flds = self.__keydict__() + flds['private_key'] = flds['private_key'][:6]+'...' + return '\n'.join( [ f'\t{n} = {v}' for n,v in flds.items() ] ) + + def __repr__(self): + return '\n'.join( [ f'\t{n} = {v}' for n,v in self.__keydict__().items() ] ) + + def __keydict__(self, keychanged:str = None) -> dict: + rtn = {'private_key': self.private_key, + 'public_key': self.public_key, + 'encoding': self.encoding.name } + if keychanged: rtn['key_changed'] = keychanged + return rtn + + def __callback__(self, keychanged:str ) -> None: + for func in self.keychange_callback_func_list: + func( self.__keydict__(keychanged) ) + + @property + def private_key(self): + return self.convert_key(self.__pv, SXTKeyEncodings.BYTES, self.encoding) + @private_key.setter + def private_key(self, value): + self.__pv = self.convert_key(value, self.get_encoding_type(value), SXTKeyEncodings.BYTES) if value else '' + self.__pb = '' + self.__callback__('private_key') + self.logger.debug(f'private key updated to { self.__pv[:6] }...') + + @property + def public_key(self): + if self.__pv and not self.__pb: + kp = KeyPair.from_private_key(PrivateKey.from_hex( self.convert_key(self.__pv, encoding_out = SXTKeyEncodings.HEX))) + self.__pb = bytes(kp.public_key.to_bytes()) + self.__callback__('public_key') + return self.convert_key(self.__pb, encoding_out= self.encoding) + @public_key.setter + def public_key(self, value): + self.__pb = self.convert_key(value, self.get_encoding_type(value), SXTKeyEncodings.BYTES) if value else '' + self.__callback__('public_key') + self.logger.debug(f'public key updated to { self.__pb }...') + + @property + def encoding(self): + return self.__en + @encoding.setter + def encoding(self, value) -> str: + if not value in SXTKeyEncodings: + raise SxTKeyEncodingError("Invalid encoding option, must be a member of SXTKeyEncodings", logger=self.logger) + self.__en = value + + def private_key_to(self, encoding_out: SXTKeyEncodings = SXTKeyEncodings.HEX): + return self.convert_key( key=self.__pv, encoding_out = encoding_out ) + + def public_key_to(self, encoding_out: SXTKeyEncodings = SXTKeyEncodings.HEX): + return self.convert_key( key=self.__pb, encoding_out = encoding_out ) + + def get_encoding_type(self, key) -> str: + """-------------------- + Accepts a key str or bytes, and returns the encoding type, [bytes, hex, base64]. + + Args: + key (any): Key to evaluate, as a string or bytes. + + Returns: + str: Encoding type, [bytes, hex, base64] + + Examples: + >>> SXTKeyManager().get_encoding_type("k6G2adpHxohA9sOBwHV8KRE5eDAJ/IEfocv5zkODgjA=") + base64 + >>> SXTKeyManager().get_encoding_type("7063e65f0ba0e2aaaeb7d240248be19fea6f68dcccb50e0f2de3e22595f84751") + hex + >>> SXTKeyManager().get_encoding_type(b'\x93\xa1\xb6i\xdaG\xc6\x88@\xf6\xc3\x81\xc0u|)\x119x0\t\xfc\x81\x1f\xa1\xcb\xf9\xceC\x83\x820') + bytes + """ + if type(key) == bytes and len(key) == 32: return SXTKeyEncodings.BYTES + try: + bytes.fromhex(key) + return SXTKeyEncodings.HEX + except: + if type(key) == str and len(key) == 44: return SXTKeyEncodings.BASE64 + raise SxTKeyEncodingError(f'Unknown Encoding: {key}', logger=self.logger) + + + def new_keypair(self) -> dict: + """-------------------- + Generate a new ED25519 keypair, set class variables and return dictionary of values. + + Returns: + dict: New keypair values + + Examples: + >>> km = SXTKeyManager(SXTKeyEncodings.BASE64) + >>> km.new_keypair + ['private_key', 'public_key'] + >>> len( km.private_key ) + 64 + >>> km.encoding = SXTKeyEncodings.BASE64 + >>> len( km.private_key ) + 44 + """ + keypair = KeyPair() + self.private_key = bytes(keypair.private_key.to_bytes()) + return { 'private_key': self.private_key + ,'public_key': self.public_key } + + + def convert_key(self, key, encoding_in:SXTKeyEncodings = SXTKeyEncodings.BYTES + , encoding_out:SXTKeyEncodings = SXTKeyEncodings.HEX): + """-------------------- + Converts a key value from one stated format into requested encoding format. + + Args: + key (any): Key value, typically either str [base64, hex] or bytes. + encoding_in (str): Encoding of supplied key, as SXTKeyEncodings + encoding_out (str): Encoding of returned key, as SXTKeyEncodings + + Return: + dict: Converted key the encoding_out encoding. + + Examples: + >>> SXTKeyManager().convert_key('0123456789abcdef', SXTKeyEncodings.HEX, SXTKeyEncodings.BASE64) + ASNFZ4mrze8= + >>> SXTKeyManager().convert_key('ASNFZ4mrze8=', SXTKeyEncodings.BASE64, SXTKeyEncodings.HEX) + 0123456789abcdef + """ + try: + # always take to bytes first + if not key: + key_bytes = bytes(b'') + elif encoding_in == SXTKeyEncodings.BYTES: + key_bytes = bytes(key) + elif encoding_in == SXTKeyEncodings.BASE64: + key_bytes = base64.b64decode(key) + elif encoding_in == SXTKeyEncodings.HEX: + key_bytes = bytes.fromhex(key) + + # format as requested encoding + if encoding_out == SXTKeyEncodings.BYTES: + key_out = key_bytes + elif encoding_out == SXTKeyEncodings.BASE64: + key_out = base64.b64encode(key_bytes).decode('utf-8') + elif encoding_out == SXTKeyEncodings.HEX: + key_out = key_bytes.hex() + + # self.logger.debug(f'Key verified and converted from {encoding_in.name} to {encoding_out.name}.') + return key_out + except Exception as ex: + error = ex + raise SxTKeyEncodingError(f'Error: {error}, going from {encoding_in.name} to {encoding_out.name}', logger=self.logger) + + + def get_KeyPair(self) ->KeyPair: + """Builds and returns a KeyPair object from current private / public key.""" + if not self.__pv: + raise ValueError('Requires valid private_key to be set') + kp = KeyPair.from_private_key( PrivateKey.from_bytes(self.__pv) ) + return kp + + + def sign_message(self, message:str, encoding_out:SXTKeyEncodings = SXTKeyEncodings.HEX): + """-------------------- + Use private key to cryptographically sign and return message. + + Args: + message (str): String message to sign with the class private key and return. + encoding_out (SXTKeyEncodings): Encoding of returned signed message, as SXTKeyEncodings + + Returns: + str | bytes: Signed message, encoded per encoded_out (or class.encoding as default) + + """ + try: + if not encoding_out: encoding_out = self.encoding + signing_object = nacl.signing.SigningKey(bytes(self.__pv)) + signed_message = signing_object.sign(message.encode('utf-8')) + return self.convert_key(signed_message.signature, SXTKeyEncodings.BYTES, encoding_out) + except Exception as ex: + error = ex + raise SxTKeyEncodingError(error, logger=self.logger) + + + def add_keychange_callback(self, func) -> None: + """Adds a function to a list of functions to call whenever a key (public or private) changes.""" + if type(self.keychange_callback_func_list) != list: self.keychange_callback_func_list = [] + self.keychange_callback_func_list.append(func) + + + def clear_keychange_callback(self) -> None: + """Clears all functions from the keychange callback list.""" + self.keychange_callback_func_list = [] + + + +if __name__ == '__main__': + + print('\n', '-=-='*10, '\n' ) + + # BASIC USAGE + key1 = SXTKeyManager(new_keypair=True) + key1.new_keypair() # or, explicitly + + # objects come with default root logger with debug msgs to stdout + key1.logger.debug( key1 ) + print( 'signed message:', key1.sign_message('my signed message') ) + + + + # Supply your own setup to the constructor + logger = logging.getLogger() + logger.setLevel(logging.INFO) + if len(logger.handlers) == 0: logger.addHandler( logging.StreamHandler() ) + + key2 = SXTKeyManager(private_key = '4G7l7Zu4zTTMsV/p3i7qjNEqf2LV92LSXfntQXWkH+c=', + encoding = SXTKeyEncodings.HEX, + keychange_callback_func = print, # function called on key-change + logger=logger + ) + # during instantiation, private key is set, and that change triggers the callback 'print' + # then the public key is regenerated, again triggering the callback 'print'. + # The two outputs have different 'key_changed' values: one private_key, one public_key. + + # casting or printing as a string (__str__) provides a human-readable string output: + print( key2 , '\n' + '-'*10 ) + + # to print the object without private key truncation, try the repr() function: + print( repr(key2) ) + + + + # you can have many callback functions, which fire on any key value change + import pprint + key2.clear_keychange_callback() + key2.encoding = SXTKeyEncodings.BASE64 + + print( '-'*10, 'adding callbacks...') + key2.add_keychange_callback( print ) + key2.add_keychange_callback( pprint.pprint ) + key2.add_keychange_callback( lambda x: print(f"\tKey that Changed: {x['key_changed']}") ) + + # triggers 6 callbacks: 3 for the private key change, which triggers 3 more with a public key rebuild + key2.private_key = 'mOXd6UzMNiR1qfQYW/MRcVANdxP+k62JGYXo2t5Ukv0=' + + # this only happens on change, not every time the item is inspected. + print( key2.public_key ) + print( key2.private_key ) + + + # the encoding property only controls the encoding OUTPUT. + # All encoding INPUT is automatically detected and stored as bytes (note debug message) + key2.clear_keychange_callback() + key2.add_keychange_callback( lambda x: print('\t', x['private_key'] ) ) + key2.logger.setLevel( logging.INFO ) + + for encoding in [ SXTKeyEncodings.HEX, SXTKeyEncodings.BASE64, SXTKeyEncodings.BYTES]: + key2.encoding = encoding + print( encoding.name ) + key2.private_key = '4G7l7Zu4zTTMsV/p3i7qjNEqf2LV92LSXfntQXWkH+c=' + key2.private_key = 'e06ee5ed9bb8cd34ccb15fe9de2eea8cd12a7f62d5f762d25df9ed4175a41fe7' + key2.private_key = b'\xe0n\xe5\xed\x9b\xb8\xcd4\xcc\xb1_\xe9\xde.\xea\x8c\xd1*\x7fb\xd5\xf7b\xd2]\xf9\xedAu\xa4\x1f\xe7' + + + # Access the native KeyPair object, if needed: + key2.encoding = SXTKeyEncodings.BYTES + privatekey_obj = key2.get_KeyPair().private_key.from_bytes( key2.private_key ) + print( privatekey_obj.to_hex() ) + + + # note: key properties will convert keys from BYTES to requested encoding + # upon inspection - even by the IDE. Try hover-over inspecting in an IDE + # and notice the debug log entries. + key2.logger.setLevel( logging.DEBUG ) + key2.encoding = SXTKeyEncodings.BASE64 + key2.private_key + key2.public_key + + pass \ No newline at end of file diff --git a/src/spaceandtime/sxtresource.py b/src/spaceandtime/sxtresource.py new file mode 100644 index 0000000..86a2bdd --- /dev/null +++ b/src/spaceandtime/sxtresource.py @@ -0,0 +1,820 @@ +import logging, datetime, json, random +from pathlib import Path +from .sxtenums import SXTResourceType, SXTPermission, SXTKeyEncodings, SXTTableAccessType +from .sxtexceptions import SxTArgumentError, SxTFileContentError +from .sxtbiscuits import SXTBiscuit +from .sxtkeymanager import SXTKeyManager +from .sxtuser import SXTUser + +class SXTResource(): + # child objects should override: self.__with__, has_with_statement(), self.resource_type + + logger: logging.Logger + resource_type:SXTResourceType = SXTResourceType.UNDEFINED + PERMISSION = SXTPermission + create_ddl:str = '' + user: SXTUser = None + filepath: Path = '' + application_name:str = '' + biscuits = [] + start_time: datetime.datetime = None + __rcn:str = '' + __ddlt__:str = '' + __allprops__: list = [] + __with__:str + + + def __init__(self, name:str=None, from_file:Path=None, default_user:SXTUser = None, + private_key:str = None, new_keypair:bool = False, key_manager:SXTKeyManager = None, + application_name:str = None, logger:logging.Logger = None) -> None: + if logger: + self.logger = logger + else: + self.logger = logging.getLogger() + self.logger.setLevel(logging.INFO) + if len(self.logger.handlers) == 0: + self.logger.addHandler( logging.StreamHandler() ) + if from_file: + self.load(from_file) + else: + if name: self.resource_name = name + if key_manager and type(key_manager)==SXTKeyManager: + self.key_manager = key_manager + else: + self.key_manager = SXTKeyManager(private_key=private_key, new_keypair=new_keypair, logger=logger, encoding=SXTKeyEncodings.BASE64 ) + self.start_time = datetime.datetime.now() + self.biscuits = [] + if default_user: self.user = default_user + self.application_name = application_name + self.__with__ = 'WITH "public_key={public_key}"' + self.__allprops__ = [self.resource_type.value, 'start_time','resource_name','resource_type','resource_name_template', + 'resource_private_key','resource_public_key','biscuits', + 'with_statement', 'create_ddl_template','create_ddl'] + + + def __str__(self) -> str: + line_formatter = lambda n,v: f"{str(n).rjust(20)} = {v}" + biscuit_formatter = lambda n,v: f"\n{str(n)}: \n{v}" + sql_formatter = lambda n,v: f"{'- '*30}\n{str(n)}: \n{v}\n" + lines = list(self.to_list(True, + func_line_formatter=line_formatter, + func_biscuit_formatter=biscuit_formatter, + func_sql_formatter=sql_formatter)) + lines.insert(0,f"\n {'-'*11}{'='*10} {self.resource_name} {'='*10}{'-'*11}" ) + return '\n'.join(lines) + + def __repr__(self) -> str: + line_formatter = lambda n,v: f"{str(n).rjust(20)} = {v}" + biscuit_formatter = lambda n,v: f"{str(n)}: \n{v}\n" + sql_formatter = lambda n,v: f"{'- '*30}\n{str(n)}: \n{v}\n" + lines = list(self.to_list(False, + func_line_formatter=line_formatter, + func_biscuit_formatter=biscuit_formatter, + func_sql_formatter=sql_formatter)) + lines.insert(0,f"\n {'-'*10}{'='*10} {self.resource_name} {'='*10}{'-'*10}" ) + return '\n'.join(lines) + + + @property + def private_key(self) ->str : + return self.key_manager.private_key + @private_key.setter + def private_key(self, value): + self.key_manager.private_key = value + self.__bt = '' + + @property + def public_key(self) ->str : + return self.key_manager.public_key + @public_key.setter + def public_key(self, value): + self.key_manager.public_key = value + + @property + def encoding(self) ->str : + return self.key_manager.encoding + @encoding.setter + def encoding(self, value): + self.key_manager.encoding = value + + @property + def resource_name_template(self) -> str: + return self.__rcn + @resource_name_template.setter + def resource_name_template(self, value): + self.__rcn = value + + @property + def resource_name(self) -> str: + tmpprops = [k for k in self.__allprops__ if k not in ['resource_name','biscuits','create_ddl',self.resource_type.value, 'with','with_statement']] + return self.replace_all(self.__rcn, self.to_dict(False, tmpprops) ) + @resource_name.setter + def resource_name(self, value): + self.__rcn = value + + @property + def recommended_filename(self) -> Path: + filename = f'./resources/{str(self.resource_type.name).lower()}--{self.resource_name}' + filename = f"{filename}--v{self.start_time.strftime('%Y%m%d%H%M%S')}.sql" + return Path(filename) + + @property + def create_ddl(self) -> str: + tmpprops = [k for k in self.__allprops__ if k not in ['biscuits','create_ddl']] + ddl = self.replace_all( mainstr= self.create_ddl_template, replace_map = self.to_dict(False, tmpprops) ).rstrip() + if self.has_with_statement(ddl): return ddl + if ddl[-1:] == ';': ddl = ddl[:-1] + return f'{ddl} \n{self.with_statement}' + @create_ddl.setter + def create_ddl(self, value): + self.__ddlt__ = str(value) + + @property + def create_ddl_template(self) -> str: + return str(self.__ddlt__) + @create_ddl_template.setter + def create_ddl_template(self, value): + self.__ddlt__ = str(value) + + @property + def with_statement(self) -> str: + tmpprops = [k for k in self.__allprops__ if k not in ['biscuits','create_ddl','create_ddl_template','with','with_statement']] + tmpencoding = self.key_manager.encoding + if tmpencoding != SXTKeyEncodings.HEX: self.key_manager.encoding = SXTKeyEncodings.HEX + rtn = self.replace_all(self.__with__, self.to_dict(False, tmpprops) ) + if tmpencoding != SXTKeyEncodings.HEX: self.key_manager.encoding = tmpencoding + return rtn + + @property + def create_ddl_sample(self) -> str: + return """ + CREATE TABLE {table_name} + ( MyID int + , MyName varchar + , MyDate date + , Primary Key (MyID) + ) {with_statement} """ + + + def new_keypair(self) -> dict: + """-------------------- + Generate a new ED25519 keypair, set class variables and return dictionary of values. + + Returns: + dict: New keypair values + + Examples: + >>> resourceA.new_keypair() + >>> len( resourceA.private_key ) + 44 + >>> resourceA.key_manager.encoding = SXTKeyEncodings.HEX + >>> len( resourceA.private_key ) + 64 + """ + return self.key_manager.new_keypair() + + + def add_biscuit_object(self, biscuit_object:SXTBiscuit) -> SXTBiscuit: + self.biscuits.append(biscuit_object) + return biscuit_object + + + def add_biscuit(self, name:str = '', *permissions) -> SXTBiscuit: + if not self.private_key: + raise SxTArgumentError('Resource requires a private key to be set before making new biscuits.', logger=self.logger) + biscuit = SXTBiscuit(name=name, private_key=self.private_key, logger=self.logger ) + biscuit.add_capability(self.resource_name, *permissions) + self.biscuits.append(biscuit) + return biscuit + + + def get_biscuit(self, by_name:str) -> list: + return [b for b in self.biscuits if b.name == by_name] + + + def clear_biscuits(self): + self.biscuits = [] + + + def replace_all(self, mainstr:str, replace_map:dict = None) -> str: + if not replace_map: replace_map = {} + if 'date' not in replace_map.keys(): replace_map['date'] = int(self.start_time.strftime('%Y%m%d')) + if 'time' not in replace_map.keys(): replace_map['time'] = int(self.start_time.strftime('%H%M%S')) + if 'resource_public_key' in replace_map.keys(): replace_map['public_key'] = replace_map['resource_public_key'] + if 'resource_private_key' in replace_map.keys(): replace_map['private_key'] = replace_map['resource_private_key'] + # if 'with_statement' in replace_map.keys(): replace_map['with'] = replace_map['with_statement'] + for findname, replaceval in replace_map.items(): + mainstr = str(mainstr).replace('{'+str(findname)+'}', str(replaceval)) + return mainstr + + + def to_json(self, obscure_private_key:bool = True, omit_keys:list = []) -> json: + """-------------------- + Returns a json document containing relevent information from the Resource object. + + Args: + obscure_private_key (bool): If True will only display first 6 characters of private keys. + omit_keys (list): List of key names to exclude from the return. + + Returns: + json: JSON representation of the class. + """ + return json.dumps(self.to_dict(obscure_private_key=obscure_private_key, omit_keys=omit_keys)) + + + def to_dict(self, obscure_private_key:bool = True, include_keys:list = []) -> dict: + """-------------------- + Returns a dictionary object containing relevent information from the Resource object. + + Args: + obscure_private_key (bool): If True will only display first 6 characters of private keys. + include_keys (list): List of key names to include in the return. Defaults to all keys. + + Returns: + dict: Curated dictionary of relevent values in the class. + """ + if include_keys ==[]: include_keys = self.__allprops__ + rtn = {} + for prop in include_keys: + match prop: + case 'resource_type': rtn[prop] = self.resource_type.name + case self.resource_type.value: rtn[prop] = self.resource_name + case 'resource_private_key': rtn[prop] = self.private_key[:6]+'...' if obscure_private_key else self.private_key + case 'resource_public_key': rtn[prop] = self.public_key + case 'with_statement': + rtn[prop] = self.with_statement + rtn['with'] = self.with_statement + case 'start_time': + rtn[prop] = self.start_time.strftime('%Y-%m-%d %H:%M:%S') + rtn['date'] = int(self.start_time.strftime('%Y%m%d')) + rtn['time'] = int(self.start_time.strftime('%H%M%S')) + case 'biscuits': + rtn[prop] = {} + for bis in self.biscuits: + if bis and type(bis) == SXTBiscuit: + rtn[prop][bis.name] = bis.biscuit_token + case _: + rtn[prop] = getattr(self, prop, str('')) + if type(rtn[prop]) == SXTTableAccessType: rtn[prop] = rtn[prop].value + return rtn + + + def to_list(self, obscure_private_key:bool = True, + include_keys:list = [], + func_line_formatter = lambda n,v: f'{n}={v}', + func_biscuit_formatter = lambda n,v: f'{n}_biscuit_token={v}', + func_sql_formatter = lambda n,v: f'{n}\n:{v}') -> list: + """------------------ + Returns a list object containing relevent information from the Resource object, with name/value formatted to one line. + + Args: + obscure_private_key (bool): If True will only display first 6 characters of private keys + omit_keys (list): List of key names to exclude from the return + func_line_formatter (function): Function that accepts two parameters (name, value) and returns a single string. Defaults to lambda n,v: f'{n}={v}' + func_biscuit_formatter (function): Same as line_formatter, but used specifically for any biscuit nested objects found + func_sql_formatter (function): Same as line_formatter, but used specifically for any names containing 'ddl' or 'sql' + + Returns: + list: List representation of the class. + """ + rtn = [] + for n,v in self.to_dict(obscure_private_key=obscure_private_key, include_keys=include_keys).items(): + if n=='biscuits': + for bname, token in dict(v).items(): + rtn.append(func_biscuit_formatter(bname, token)) + elif 'ddl' in n or 'sql' in n: + rtn.append(func_sql_formatter(n,v)) + else: + rtn.append(func_line_formatter(n,v)) + return rtn + + + def get_first_valid_user(self, *users) -> SXTUser: + users = [user for user in list(users) + [self.user] if type(user) == SXTUser and not user.access_expired] + if users == []: + raise SxTArgumentError('SXT authenticated User must be provided as an argument to create the resource.', logger=self.logger) + return users[0] + + + def create(self, sql_text:str = None, user:SXTUser = None, biscuits:list = None): + """-------------------- + Issues the supplied (parameterized) CREATE statement to the Space and Time network, and report back success and details. + + Args: + sql_text (str): Parameterized CREATE statement. If omitted, will use the resource.create_ddl class property. Both will replace {placeholders} with real values before submission. + user (SXTUser): Authenticated user who will issue the command. If omitted, will use the default user, resource.user + biscuits (list): List of biscuits to include with the request, either as string biscuit tokens or as SXTBiscuit objects. If omitted, will use the class.biscuits list. Must contain CREATE permissions. + + Returns: + bool: Success flag, True if the object was created. + object: other details supplied during the request, including API messaging. Typically a dict. + """ + user = self.get_first_valid_user(user) + if not sql_text: + if self.create_ddl_template == '': + raise SxTArgumentError('Must set the create_ddl before trying to create the table.', logger=self.logger) + sql_text = self.create_ddl + if self.private_key == '': + raise SxTArgumentError('Must create or set a keypair before trying to create the table. Try running new_keypair().', logger=self.logger) + if not biscuits: biscuits = self.biscuits if type(self.biscuits)==list else [self.biscuits] + if biscuits == []: + self.logger.warning('No biscuits found. While this may be OK, it can also cause errors.', logger=self.logger) + success, result = user.base_api.sql_ddl(sql_text=sql_text.strip(), biscuits=biscuits, app_name=self.application_name) + if success: + self.logger.info(f'{self.resource_type.name} Created: {self.resource_name}:\n{sql_text}') + else: + self.logger.error(f'{self.resource_type.name} FAILED TO CREATE with user {user.user_id}:\n{result}\n{sql_text}\n\nBiscuits: {biscuits}') + return success, result + + + def drop(self, user:SXTUser = None, biscuits:list = None): + """-------------------- + Issues the supplied (parameterized) DROP statement to the Space and Time network, and report back success and details. + + Args: + user (SXTUser): Authenticated user who will issue the command. If omitted, will use the default user, resource.user + biscuits (list): List of biscuits to include with the request, either as string biscuit tokens or as SXTBiscuit objects. If omitted, will use the class.biscuits list. Must contain DROP permissions. + + Returns: + bool: Success flag, True if the object was dropped. + object: other details supplied during the request, including API messaging. Typically a dict. + """ + self.logger.info(f'{"-"*15}\nDROPPING {self.resource_type.name}: {self.resource_name}...') + user = self.get_first_valid_user(user) + if not biscuits: biscuits = list(self.biscuits) + if biscuits == []: + raise SxTArgumentError('A biscuit with DROP must be included.', logger=self.logger) + sql_text = f'DROP {self.resource_type.name} {self.resource_name}' + success, result = user.base_api.sql_ddl(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name) + if success: + self.logger.info(f' DROPPED: {self.resource_name}') + else: + self.logger.error(f'{self.resource_type.name} FAILED TO DROP with user {user.user_id}:\n{result}\n{sql_text}') + return success, result + + + def select(self, sql_text:str = '', columns:list = ['*'], user:SXTUser = None, biscuits:list = None, row_limit:int = 20) -> json: + """-------------------- + Issues a SELECT statement to the Space and Time network, and report back success and rows (or failure details). + + This is intended as a convenience feature, to quickly verify data structures or recently loaded data. While it can + run more sophisticated SQL, it is recommended to use the SXTUser object for more flexibility. + + Args: + sql_text (str): Sql text to execute. If omitted, will defaults to "SELECT [columns] FROM [resource_name] LIMIT [row_limit]". + columns (list): List of columns to build the SELECT statement. Defaults to "*". If sql_text is supplied, this is ignored. + user (SXTUser): Authenticated user who will issue the command. If omitted, will use the default user, resource.user + biscuits (list): List of biscuits to include with the request, either as string biscuit tokens or as SXTBiscuit objects. If omitted, will use the class.biscuits list. + row_limit (int): Limits the number of rows returned. + + Returns: + bool: Success flag, True if the object was dropped. + object: Row output of the SQL request, in JSON format, or if error, details returned from the request. + + Examples: + >>> suzy = SXTUser('.env', authenticate=True) + >>> ethblocks = SXTTable(name='ETHEREUM.Blocks', default_user=suzy) + >>> success, rows = ethblocks.select (columns = 'BLOCK_NUMBER', row_limit = 10) + >>> len( rows ) + 10 + >>> len(rows[0].keys()) + 11 + """ + self.logger.info(f'{"-"*15}\nSELECTing {self.resource_type.name} {self.resource_name}...') + user = self.get_first_valid_user(user) + if not biscuits: biscuits = list(self.biscuits) + if biscuits == []: + self.logger.warning('No biscuits found. While this may be OK, it can also cause errors.') + if sql_text == '': sql_text = f"SELECT { ','.join( columns ) } FROM {self.resource_name} LIMIT {row_limit}" + self.logger.info(f'{self.resource_type.name} Query Started: {self.resource_name}:\n{sql_text}') + success, result = user.base_api.sql_dql(sql_text=sql_text, biscuits=biscuits, resources=self.resource_name, app_name=self.application_name) + if success: + self.logger.info(f'{self.resource_type.name} {self.resource_name} Finished: {len(result)} Rows Returned') + else: + self.logger.error(f'{self.resource_type.name} QUERY FAILED with user {user.user_id}:\n{result}\n{sql_text}') + return success, result + + + def clear_all(self) -> None: + """Clears all content from the object. It is HIGHLY RECOMMENDED you save() before a clear_all(), to prevent key loss. No arguments and None returned.""" + self.clear_biscuits() + props = [p for p in self.__allprops__ if p not in ['resource_type','with_statement']] + for prop in props: + setattr(self, prop, '') + self.start_time = datetime.datetime.now() + self.key_manager = SXTKeyManager(logger=self.logger, encoding=SXTKeyEncodings.BASE64) + # TODO: add a 'dirty' flag, and warn if not saved + self.logger.info(f'{self.resource_type.name} resource has been cleared.') + return None + + + def __filestarts__(self, folderfilepath:Path) -> Path: + if Path(folderfilepath).exists(): return Path(folderfilepath) + folderfilepath = Path(folderfilepath).resolve() + files = sorted([str(file) for file in list(Path(folderfilepath.parent).iterdir()) if str(Path(file).name).startswith(folderfilepath.name)]) + return Path(files[-1:][0]) if len(files) > 0 else None + + + def load(self, filepath:Path, find_latest:bool = False ): + """-------------------- + Loads Resource file *WITH PRIVATE KEYS* to the current object, overwriting all current values. + + The load is expecting a plain-text file in a shell-loadable format, meaning you can run the input file in a + terminal /shell, and it will load into environment variables. This is the same file that the save() function + produces. Any NAME=Value format is translated into object variables, including heredocs using the EOM marker. + For examples, look at the save() file produced. To prevent losing keys, it is recommended you always + save() before you load(). + + Args: + filepath (Path): File to load into object. + find_latest (bool): If True, will accept incomplete filename and search the parent folder for the last matching. This works well in tandum with the recommended_filename to load the most recent file. Defaults to False (off). + + Returns: + bool: True if load was successful, False if not. + + """ + self.clear_all() + filepath = self.__filestarts__(filepath) if find_latest else Path(filepath) + if not filepath or not filepath.exists(): + raise FileNotFoundError(f'Resource file not found: {filepath}') + + try: + # load and clean file content + with open(Path(filepath).resolve()) as fh: + lines = fh.read().replace('\nEOM\n)\n','\n::E::').replace('$(cat << EOM','::S::').split('\n') + lines = [l for l in lines if not (l.strip()=='' + or l.startswith('#') + or l.startswith('DATE=') + or l.startswith('TIME=') + or l.startswith('WITH=') + or l.startswith('WITH_STATEMENT=') + or l.startswith('RESOURCE_TYPE=') + or l.startswith('RESOURCE_PUBLIC_KEY=') )] + + # loop thru and build dict to control load + loadmap = {} + multiline = None + lines = iter(lines) # so we can do next() + for line in lines: + eq = line.find('=') + name = line[:eq] + value = line[eq+1:] + if value[:1]=='"' and value[-1:]=='"': value = value[1:-1] + if '::S::' in value: + multiline = [] + mline = '' + while True: + mline = next(lines, '') + if '::E::' in mline: break + multiline.append( mline ) + value = '\n'.join(multiline) + loadmap[name.lower()] = value + + # with loadmap, load into object (sorted, to prevent create_ddl / _template overwriting) + loadmap = {k:loadmap[k] for k in sorted(list(loadmap.keys()))} + for name, value in loadmap.items(): + if name == 'start_time': + setattr(self, name, datetime.datetime.strptime(value, '%Y-%m-%d %H:%M:%S') ) + elif name == 'resource_private_key': + self.key_manager = SXTKeyManager(private_key=value, encoding=SXTKeyEncodings.BASE64, logger=self.logger) + elif name.endswith( '_biscuit_token'): + if type(self.biscuits) != list: self.biscuits = [] + self.biscuits.append(SXTBiscuit(name=name.replace('_biscuit_token',''), logger=self.logger, + private_key=self.private_key, + biscuit_token= value)) + else: + setattr(self, name, value) + return True + except Exception as ex: + err = ex + raise SxTFileContentError(f'Failed to load file {filepath}: {err}') + + + def save(self, filepath:Path = None): + """-------------------- + Saves Resource file *WITH PRIVATE KEYS* to the specified filepath. Will not overwrite. + + The format saved is a plain-text file in a shell-loadable format, meaning you can run the output file in a + terminal /shell, and it will load into environment variables. This format can also be loaded into the python + SDK using the load() command. To prevent lost keys, this process will specifically NOT OVERWRITE files. It is + best practice to version files as needed and keep history. + + Args: + filepath (Path): Where to save the file. If filepath is None, it will use the object's recommended_filename. + + Returns: + bool: True if save was successful, False if not. + + """ + filepath = Path(filepath) if filepath else Path(self.recommended_filename) + line_formatter = lambda n,v: f'{str(n).upper()}="{v}"' + biscuit_formatter = lambda n,v: f'{str(n).upper()}_BISCUIT_TOKEN="{v}"' + sql_formatter = lambda n,v: f'{str(n).upper()}=$(cat << EOM\n{v}\nEOM\n)\n' + lines = list(self.to_list(obscure_private_key=False, + func_line_formatter=line_formatter, + func_biscuit_formatter=biscuit_formatter, + func_sql_formatter=sql_formatter, + include_keys=[p for p in self.__allprops__ if p not in ['with','with_statement']])) + lines.insert(0, f'# -- Resource File for {self.resource_name}') + lines.insert(1, f'# -- this file can be executed as a shell script to set environment variables') + for i, line in enumerate(lines): + if str(line).startswith('CREATE_DDL'): + lines.insert(i, '# -- SQL:') + break + for i, line in enumerate(lines): + if 'BISCUIT_TOKEN' in line: + lines.insert(i, '# -- BISCUITS:') + break + fp = Path(self.replace_all(str(filepath), self.to_dict(False))).resolve() + self.logger.info(f'Saving Resource File: {fp}') + if fp.exists(): + self.logger.error(f'File Exists: {fp.resolve()}\nTo minimize lost keys, file over-writes are not allowed.') + raise FileExistsError('To minimize lost keys, file over-writes are not allowed.') + else: + if not fp.parent.exists(): fp.parent.mkdir(parents=True, exist_ok=True) + try: + with open(fp.resolve(), 'w') as fh: + fh.write( '\n'.join(lines) ) + except Exception as ex: + self.logger.error(f'Error while saving Resource File: {ex}') + return False + return True + + + def has_with_statement(self, create_ddl:str) ->bool: + """------------------- + Returns True/False as to whether supplied Create Resource SQL has a WITH statement. + + Args: + create_ddl (str): The Create Resource DDL / SQL to analyze for a WITH statement. + + Returns: + bool: True if the WITH statement was found, FALSE if not. + """ + create_ddl = create_ddl.replace('{with_statement}','').replace('{with}','') + if 'with' not in create_ddl.lower(): return False + r6 = '' # rolling 6 chars + for i in range(len(create_ddl)-6, 0, -1): + r6 = create_ddl[i:i+6] + if r6[1:5].lower() == 'with' and not r6[0:1].isalnum() and not r6[5:6].isalnum(): return True + if r6[0:1] == ')': return False + return False + + + + +class SXTTable(SXTResource): + access_type: SXTTableAccessType + immutable:bool = False + + def __init__(self, name:str='', from_file:Path=None, default_user:SXTUser = None, + private_key:str = '', new_keypair:bool = False, key_manager:SXTKeyManager = None, + access_type:SXTTableAccessType = SXTTableAccessType.PERMISSSIONED, + application_name:str = None, logger:logging.Logger = None) -> None: + self.resource_type = SXTResourceType.TABLE + super().__init__(name, from_file, default_user, private_key, new_keypair, key_manager, application_name, logger) + self.access_type = access_type + self.__allprops__.insert(2, 'immutable') + self.__allprops__.insert(2, 'access_type') + self.__with__= 'WITH "public_key={public_key}, access_type={access_type}, immutable={immutable}"' + + @property + def table_name(self) ->str: + return self.resource_name + @table_name.setter + def table_name(self, value): + self.resource_name = value + + + def insert(self, sql_text:str = None, columns:list = None, data:list = None, user:SXTUser = None, biscuits:list = None): + user = self.get_first_valid_user(user) + if not biscuits: biscuits = list(self.biscuits) + if biscuits == []: + raise SxTArgumentError('A biscuit with INSERT permissions must be included.', logger=self.logger) + if not sql_text: + sql_text_prefix = f"INSERT INTO {self.table_name} ({ ', '.join(columns) }) VALUES \n" + sql_text_rows = [] + for row in data: + sql_text_rows.append( "('" + str("', '").join([str(val) for val in row]) + "')" ) + sql_text = sql_text_prefix + ',\n'.join(sql_text_rows) + return user.base_api.sql_dml(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name,resources=[self.table_name]) + + + def delete(self, sql_text:str = None, where:str = '0=1', user:SXTUser = None, biscuits:list = None): + user = self.get_first_valid_user(user) + if not biscuits: biscuits = list(self.biscuits) + if biscuits == []: + raise SxTArgumentError('A biscuit with DELETE permissions must be included.', logger=self.logger) + if len(where) >0 and not str(where).strip().startswith('where'): where = f' WHERE {where} ' + if not sql_text: sql_text = f"DELETE FROM {self.table_name} {where}" + return user.base_api.sql_dml(sql_text=sql_text, biscuits=biscuits, app_name=self.application_name, resources=[self.table_name]) + + + + + +class SXTView(SXTResource): + def __init__(self, name:str='', from_file:Path=None, default_user:SXTUser = None, + private_key:str = '', new_keypair:bool = False, key_manager:SXTKeyManager = None, + application_name:str = None, logger:logging.Logger = None) -> None: + self.resource_type = SXTResourceType.VIEW + super().__init__(name, from_file, default_user, private_key, new_keypair, key_manager, application_name, logger) + self.__with__= ' WITH "public_key={public_key}" ' + + @property + def view_name(self) -> str: + return self.resource_name + @view_name.setter + def view_name(self, value): + self.resource_name = value + + @property + def table_biscuit(self) -> SXTBiscuit: + rtn = [b for b in self.biscuits if b.__parentbiscuit__] + if len(rtn) >0: + return rtn[0] + else: + return None + @table_biscuit.setter + def table_biscuit(self, value): + if type(value) == list and len(value) >0: value = value[0] + if len(value)==0: return None + if not type(value) == SXTBiscuit: + raise SxTArgumentError('Table_biscuit must be of type SXTBiscuit or a list of SXTBiscuits.') + for biscuit in self.biscuits: + if biscuit.__parentbiscuit__: biscuit=None + self.biscuits = [b for b in self.biscuits if b] + value.__parentbiscuit__ = True + self.biscuits.append(value) + + @property + def create_ddl_sample(self) -> str: + return """ + CREATE VIEW {view_name} + {with_statement} + AS + SELECT * + FROM MySchema.MyTable """ + + def has_with_statement(self, create_ddl:str) ->bool: + """------------------- + Returns True/False as to whether supplied Create Resource SQL has a WITH statement. + + Args: + create_ddl (str): The Create Resource DDL / SQL to analyze for a WITH statement. + + Returns: + bool: True if the WITH statement was found, FALSE if not. + """ + create_ddl = create_ddl.lower().strip().replace('\n',' ').replace('\t',' ').replace(' ',' ').replace(' ',' ').replace(' ',' ') + if '{with_statement}' in create_ddl or '{with}' in create_ddl: return True + if 'with' not in create_ddl.lower(): return False + pass + r6 = '' # rolling 6 chars + for i in range(0, len(create_ddl)-6, 1): + r6 = create_ddl[i:i+6] + if r6[1:5].lower() == 'with' and not r6[0:1].isalnum() and not r6[5:6].isalnum(): return True + if r6[1:5].lower() == ' as ': return False + return False + + + + +class SXTMaterializedView(SXTResource): + __ri__: int + + def __init__(self, name:str='', from_file:Path=None, default_user:SXTUser = None, + private_key:str = '', new_keypair:bool = False, key_manager:SXTKeyManager = None, + application_name:str = None, logger:logging.Logger = None) -> None: + self.resource_type = SXTResourceType.MATERIALIZED_VIEW + super().__init__(name, from_file, default_user, private_key, new_keypair, key_manager, application_name, logger) + self.__ri__ = 1440 + self.__allprops__.insert(2, 'refresh_interval') + self.__with__= ' WITH "public_key={public_key} , refresh_interval={refresh_interval}" ' + + @property + def matview_name(self) ->str: + return self.resource_name + @matview_name.setter + def matview_name(self, value): + self.resource_name = value + + @property + def refresh_interval(self) -> int: + return self.__ri__ + @refresh_interval.setter + def refresh_interval(self, value): + if value >= 1440: + self.__ri__ = value + else: + raise SxTArgumentError('Current limit to a Materialized View refresh is once every 24 hours // 1440 minutes', logger=self.logger) + + @property + def create_ddl_sample(self) -> str: + return """ + CREATE MATERIALIZED VIEW {view_name} + {with_statement} + AS + SELECT * + FROM MySchema.MyTable """ + + def has_with_statement(self, create_ddl:str) ->bool: + """------------------- + Returns True/False as to whether supplied Create Resource SQL has a WITH statement. + + Args: + create_ddl (str): The Create Resource DDL / SQL to analyze for a WITH statement. + + Returns: + bool: True if the WITH statement was found, FALSE if not. + """ + create_ddl = create_ddl.lower().strip().replace('\n',' ').replace('\t',' ').replace(' ',' ').replace(' ',' ').replace(' ',' ') + if '{with_statement}' in create_ddl or '{with}' in create_ddl: return True + if 'with' not in create_ddl.lower(): return False + pass + r6 = '' # rolling 6 chars + for i in range(0, len(create_ddl)-6, 1): + r6 = create_ddl[i:i+6] + if r6[1:5].lower() == 'with' and not r6[0:1].isalnum() and not r6[5:6].isalnum(): return True + if r6[1:5].lower() == ' as ': return False + return False + + + + + +if __name__ == '__main__': + from pprint import pprint + print('\n', '-=-='*10, '\n' ) + + # Create a user and authenticate: + suzy = SXTUser('.env', authenticate=True) + + + # Create a table, using a new keypair + tableA = SXTTable(name='SXTTEMP.New_TableName', new_keypair=True, default_user=suzy) + tableA.add_biscuit('Read', tableA.PERMISSION.SELECT) + tableA.add_biscuit('Load', tableA.PERMISSION.SELECT, tableA.PERMISSION.INSERT, tableA.PERMISSION.UPDATE, tableA.PERMISSION.DELETE, tableA.PERMISSION.MERGE) + tableA.add_biscuit('Admin', tableA.PERMISSION.ALL) + tableA.create_ddl = """ + CREATE TABLE {table_name} + ( MyID int + , MyName varchar + , MyDate date + , Primary Key (MyID) + ) + """ + tableA.immutable = True + tableA.save() # save to local file, to prevent lost keys + success, results = tableA.create() # Create table on Space and Time network + + + if success: + + # generate some dummy data + cols = ['MyID','MyName','MyDate'] + data = [[i, chr(64+i), f'2023-09-0{i}'] for i in list(range(1,10))] + + # perform insert + tableA.insert(columns=cols, data=data) + + # select records back, just to verify + success, results = tableA.select() + pprint(results if success else f'Error! {results}') + + + # create a view based on the above table, with the same keys + viewA = SXTView(name="SXTTEMP.New_ViewName", default_user=suzy, private_key=tableA.private_key) + viewA.add_biscuit('Admin', viewA.PERMISSION.ALL) + viewA.table_biscuit = tableA.get_biscuit('Admin') + viewA.create_ddl = "CREATE VIEW {view_name} {with_statement} AS SELECT * from " + tableA.table_name + + success, results = viewA.create() + pprint(f'Success! {results}' if success else f'Error! {results}') + + if success: + success, results = viewA.select() + pprint(results if success else f'Error! {results}') + + # drop the view + success, results = viewA.drop() + pprint(f'Success! {results}' if success else f'Error! {results}') + + # drop the table + success, results = tableA.drop() + print( f'success? {success}\nData: {results}' ) + + + + + # find the latest save file, load it, and perform a drop (in cases it was interrupted above): + if False: + tableA = SXTTable(default_user=suzy) + tableA.load(filepath = './resources/table--SXTTEMP.New_TableName--v202309271907', find_latest = True) + viewA = SXTView(name='SXTTEMP.New_ViewName', private_key=tableA.private_key, default_user=suzy) + viewA.add_biscuit('admin', viewA.PERMISSION.ALL) + viewA.table_biscuit = tableA.get_biscuit('admin') + success, result = viewA.drop() + success, result = tableA.drop() + + pass \ No newline at end of file diff --git a/src/spaceandtime/sxtuser.py b/src/spaceandtime/sxtuser.py new file mode 100644 index 0000000..69e45a6 --- /dev/null +++ b/src/spaceandtime/sxtuser.py @@ -0,0 +1,313 @@ +import os, logging, datetime +from .sxtexceptions import SxTAuthenticationError, SxTArgumentError +from .sxtkeymanager import SXTKeyManager, SXTKeyEncodings +from .sxtbaseapi import SXTBaseAPI, SXTApiCallTypes +from pathlib import Path +from dotenv import load_dotenv + +class SXTUser(): + user_id: str = '' + api_url: str = '' + logger: logging.Logger = None + key_manager: SXTKeyManager = None + ENCODINGS = SXTKeyEncodings + base_api: SXTBaseAPI = None + access_token: str = '' + refresh_token: str = '' + access_token_expire_epoch: int = 0 + refresh_token_expire_epoch: int = 0 + auto_reauthenticate:bool = False + start_time:datetime.datetime = None + __bs: list = None + __usrtyp__:list = None + + def __init__(self, dotenv_file:Path = None, user_id:str = None, user_private_key:str = None, + encoding:SXTKeyEncodings = None, authenticate:bool = False, logger:logging.Logger = None) -> None: + if logger: + self.logger = logger + else: + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG) + if len(self.logger.handlers) == 0: + self.logger.addHandler( logging.StreamHandler() ) + self.logger.info('-'*30 + '\nNew SXT User initiated') + encoding = encoding if encoding else SXTKeyEncodings.BASE64 + self.key_manager = SXTKeyManager(private_key = user_private_key, encoding = encoding, logger=self.logger) + self.base_api = SXTBaseAPI(logger = self.logger) + self.start_time = datetime.datetime.now() + self.__bs = [] + self.__usrtyp__ = {'type':'', 'timeout':datetime.datetime.now()} + + # from dotenv file, if exists + dotenv_file = Path('./.env') if not dotenv_file and Path('./.env').resolve().exists() else dotenv_file + if dotenv_file: self.load(dotenv_file) + + # overwrite userid and private key (and public key, by extension), if supplied + if user_private_key: self.private_key = user_private_key + if user_id: self.user_id = user_id + + self.logger.info(f'SXTUser user Created: \n{ self }') + + if authenticate: self.authenticate() + + + @property + def private_key(self) ->str : + return self.key_manager.private_key + @private_key.setter + def private_key(self, value): + self.key_manager.private_key = value + + @property + def public_key(self) ->str : + return self.key_manager.public_key + @public_key.setter + def public_key(self, value): + self.key_manager.public_key = value + + @property + def encoding(self) ->str : + return self.key_manager.encoding + @encoding.setter + def encoding(self, value): + self.key_manager.encoding = value + + @property + def access_token_expire_datetime(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.access_token_expire_epoch/1000) + + @property + def refresh_token_expire_datetime(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.refresh_token_expire_epoch/1000) + + @property + def access_expired(self) -> bool: + return datetime.datetime.now() > self.access_token_expire_datetime + + @property + def refresh_expired(self) -> bool: + return datetime.datetime.now() > self.refresh_token_expire_datetime + + @property + def user_type(self) -> str: + if self.__usrtyp__['type'] == '' or self.__usrtyp__['timeout'] <= datetime.datetime.now(): + success, users = self.base_api.subscription_get_users() + if success and self.user_id in users['roleMap']: + self.__usrtyp__['type'] = str(users['roleMap'][self.user_id]).lower() + self.__usrtyp__['timeout'] = datetime.datetime.now() + datetime.timedelta(minutes=15) + return self.__usrtyp__['type'] + else: + return 'disconnected - authenticate to retrieve' + else: + return self.__usrtyp__['type'] + + @property + def recommended_filename(self) -> Path: + filename = f'./users/{self.user_id}.env' + return Path(filename) + + + def __str__(self): + flds = {fld: getattr(self, fld) for fld in ['api_url','user_id','private_key','public_key','encoding']} + flds['private_key'] = flds['private_key'][:6]+'...' + return '\n'.join( [ f'\t{n} = {v}' for n,v in flds.items() ] ) + + + def new_keypair(self): + """-------------------- + Generate a new ED25519 keypair, set class variables and return dictionary of values. + + Returns: + dict: New keypair values + + Examples: + >>> user = SXTUser() + >>> user.new_keypair() + ['private_key', 'public_key'] + >>> len( user.private_key ) + 64 + >>> user.encoding = SXTKeyEncodings.BASE64 + >>> len( user.private_key ) + 44 + """ + return self.key_manager.new_keypair() + + + def load(self, dotenv_file:Path = None): + """Load dotenv (.env) file / environment variables: API_URL, USERID, USER_PUBLIC_KEY, USER_PRIVATE_KEY, optionally USER_JOINCODE, USER_KEY_SCHEME, APP_PREFIX. + + Args: + dotenv_file (Path): Path to .env file. If not set, first default is the file ./.env, second defalut is to load existing environment variables. + + Returns: + None + """ + load_dotenv(dotenv_file, override=True) + self.api_url = os.getenv('API_URL') + self.user_id = os.getenv('USERID') + self.private_key = os.getenv('USER_PRIVATE_KEY') + + # TODO: Right now, only ED25519 authentication is supported. Add Eth wallet support, or other future schemes + # self.key_scheme = os.getenv('USER_KEY_SCHEME') + + loc = str(dotenv_file) if dotenv_file and Path(dotenv_file).exists() else 'default .env location' + self.logger.info(f'dotenv loaded\n{ self }') + return None + + + def save(self, dotenv_file:Path = None): + """Save dotenv (.env) file containing variables: API_URL, USERID, USER_PUBLIC_KEY, USER_PRIVATE_KEY, optionally USER_JOINCODE, USER_KEY_SCHEME, APP_PREFIX. + + Args: \n + dotenv_file -- full path to .env file, defaulting to ./users/{user_id}.env if not supplied. Note: to minimize losing keys, overwrites are disallowed. + + Results: \n + None + """ + if not dotenv_file: dotenv_file = self.recommended_filename + dotenv_file = Path(self.replace_all(str(dotenv_file))).resolve() + if dotenv_file.exists(): + self.logger.error(f'File Exists: {dotenv_file}\nTo minimize lost keys, file over-writes are not allowed.') + raise FileExistsError('To minimize lost keys, file over-writes are not allowed.') + + try: + fieldmap = { 'api_url':'API_URL' + ,'user_id':'USERID' + ,'private_key':'USER_PRIVATE_KEY' + ,'public_key':'USER_PUBLIC_KEY' + } + + # build insert string for env file + hdr = '# -------- Below was added by the SxT SDK' + lines = [hdr] + for pyname, envname in fieldmap.items(): + lines.append( f'{envname}="{ getattr(self, pyname) }"' ) + + dotenv_file = Path(dotenv_file) + dotenv_file.parent.mkdir(parents=True, exist_ok=True) + i=0 + + if dotenv_file.exists(): + with open(dotenv_file.resolve(), 'r') as fh: # open file + for line in fh.readlines(): # read each line + val = str(line).split('=')[0].strip() # get text before "=" + if val and val != hdr and \ + val not in list(fieldmap.values()): # if text doesn't exist in fieldmap values + lines.insert(i,str(line).strip()) # add it, so it gets written to new file + i+=1 # preserve the original order of the file + + # create (overwrite) file + with open(dotenv_file.resolve(), 'w') as fh: + fh.write( '\n'.join(lines) ) + + self.logger.debug(f'saved dotenv file to: { dotenv_file }') + self.logger.warning('THE SAVED FILE CONTAINS PRIVATE KEYS!') + return None + + except Exception as err: + msg = f'Attempting to write new .env file to {dotenv_file}\n{ str(err) }' + self.logger.error(msg) + raise FileNotFoundError(msg) + + + def replace_all(self, mainstr:str, replace_map:dict = None) -> str: + if not replace_map: replace_map = {'user_id':self.user_id, 'public_key':self.public_key, 'start_time':self.start_time.strftime('%Y-%m-%d %H:%M:%S')} + if 'date' not in replace_map.keys(): replace_map['date'] = int(self.start_time.strftime('%Y%m%d')) + if 'time' not in replace_map.keys(): replace_map['time'] = int(self.start_time.strftime('%H%M%S')) + for findname, replaceval in replace_map.items(): + mainstr = str(mainstr).replace('{'+str(findname)+'}', str(replaceval)) + return mainstr + + + + def authenticate(self) -> str: + return self.register_new_user() + + def register_new_user(self, join_code:str = None) -> str: + """-------------------- + Authenticate to the Space and Time network, and store access_token and refresh_token. + """ + if not (self.user_id and self.private_key): + raise SxTArgumentError('Must have valid UserID and Private Key to authenticate.', logger=self.logger) + + try: + success, response = self.base_api.get_auth_challenge_token(user_id = self.user_id, joincode=join_code) + if success: + challenge_token = response['authCode'] + signed_challenge_token = self.key_manager.sign_message(challenge_token) + success, response = self.base_api.get_access_token(self.user_id, self.public_key, challenge_token, signed_challenge_token) + if success: + tokens = response + else: + raise SxTAuthenticationError(str(response), logger=self.logger) + if len( [v for v in tokens if v in ['accessToken','refreshToken','accessTokenExpires','refreshTokenExpires']] ) < 4: + raise SxTAuthenticationError('Authentication produced incorrect / incomplete output', logger=self.logger) + except SxTAuthenticationError as ex: + return False, [ex] + self.access_token = tokens['accessToken'] + self.refresh_token = tokens['refreshToken'] + self.access_token_expire_epoch = tokens['accessTokenExpires'] + self.refresh_token_expire_epoch = tokens['refreshTokenExpires'] + self.base_api.access_token = tokens['accessToken'] + return True, self.access_token + + + def reauthenticate(self) -> str: + """Re-authenticate an existing access_token to the Space and Time network.""" + if not self.refresh_expired: + raise SxTArgumentError('Refresh token has expired', logger=self.logger) + try: + success, tokens = self.base_api.token_refresh(self.refresh_token) + if not success: + raise SxTAuthenticationError(str(tokens), logger=self.logger) + if len( [v for v in tokens if v in ['accessToken','refreshToken','accessTokenExpires','refreshTokenExpires']] ) < 4: + raise SxTAuthenticationError('Authentication produced incorrect / incomplete output', logger=self.logger) + except SxTAuthenticationError as ex: + return False, [ex] + self.access_token = tokens['accessToken'] + self.refresh_token = tokens['refreshToken'] + self.access_token_expire_epoch = tokens['accessTokenExpires'] + self.refresh_token_expire_epoch = tokens['refreshTokenExpires'] + self.base_api.access_token = self.access_token + return True, self.access_token + + + def execute_sql(self, sql_text:str, biscuits:list = None, app_name:str = None): + return self.base_api.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=app_name) + + def generate_joincode(self, role:str = 'member'): + success, results = self.base_api.subscription_invite_user(role) + if not success: + self.logger.error(str(results)) + return str(results) + self.logger.info('Generated {} joincode') + return results['text'] + + def join_subscription(self, joincode:str): + success, results = self.base_api.subscription_join(joincode=joincode) + if not success: + self.logger.error(str(results)) + return False, str(results) + return True, 'Consumed join_code and joined subscription!' + + +if __name__ == '__main__': + + # BASIC USAGE + user = SXTUser(user_id = 'suzy') + user.key_manager.new_keypair() + user.save('test/suzy.env') + print( user ) + + suzy = SXTUser(encoding = SXTKeyEncodings.HEX) + suzy.load('test/suzy.env') + print( suzy ) + + bill = SXTUser(user_id='bill', user_private_key='Z833BwZcwotJf4zVA89HlyvxH8xqAUOXzTcR1dWhsrk=') + bill.save('test/bill.env') + print( bill ) + + stephen = SXTUser() + stephen.authenticate() + + pass \ No newline at end of file diff --git a/validation.py b/validation.py deleted file mode 100644 index 7ec924d..0000000 --- a/validation.py +++ /dev/null @@ -1,54 +0,0 @@ -import re -import base64 -import binascii -from Cryptodome.Cipher import AES - -def validate_number(number_val): - if not isinstance(number_val, int): - raise TypeError("Expected Integer but got {}".format(type(number_val).__name__)) - - -def validate_string(input_string): - if not isinstance(input_string, str): - raise TypeError("Expected string, but got {}".format(type(input_string).__name__)) - -def validate_boolean(input_bool): - if not isinstance(input_bool, bool): - raise TypeError("Expected boolean, but got {}".format(type(input_bool).__name__)) - -# Checking if the given string is Base64 URL Safe Encoded or not. - -def is_base64(key): - base64_regex = r'^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$' - if not re.match(base64_regex, key): - raise ValueError("String is not base64 encoded") - return True - - -def check_prefix_and_joincode(prefix, join_code): - if not isinstance(prefix, str) or not isinstance(join_code, str): - error_prefix = '' if isinstance(prefix, str) else f"Unexpected type of {type(prefix)} for prefix" - error_joincode = '' if isinstance(join_code, str) else f"{' and' if not isinstance(prefix, str) else ''} Unexpected type of {type(join_code)} for joincode" - raise ValueError(f"{error_prefix}{error_joincode}") - -VALID_DB_RESOURCE_IDENTIFIER = "^[A-Z_][A-Z0-9_]+$" -INVALID_RESOURCEID = "Invalid resourceId" -DEFAULT_SCHEMA = "PUBLIC" - -def is_valid_database_identifier(input): - return re.match(VALID_DB_RESOURCE_IDENTIFIER, input) is not None - -def try_parse_identifier(resource_id): - parts = resource_id.upper().split(".") - if len(parts) == 0 or len(parts) > 2: - raise ValueError(INVALID_RESOURCEID + ": Provided table identifier format is invalid") - elif len(parts) == 1: - schema_name = DEFAULT_SCHEMA - table_name = parts[0] - else: - schema_name = parts[0] - table_name = parts[1] - if not is_valid_database_identifier(schema_name) or not is_valid_database_identifier(table_name): - raise ValueError(INVALID_RESOURCEID + ": Either schema or table identifier is invalid") - return (schema_name, table_name) -