From e25ba9a73f1761022ee92ea3dee2723120e74ddd Mon Sep 17 00:00:00 2001 From: Stephen Hilton <Stephen.Hilton@SpaceAndTime.io> Date: Fri, 6 Oct 2023 17:32:18 -0700 Subject: [PATCH 1/3] version2 with docs and examples --- .env.sample | 5 - .gitignore | 5 +- MANIFEST.in | 1 + README.MD | 359 ++++++------- authorize.datalog | 10 - keygen.py | 44 -- main.py | 192 ------- pyproject.toml | 44 ++ requirements.txt | 6 - setup.py | 25 + spaceandtimesdk.py | 640 ----------------------- src/spaceandtime/.env.sample | 4 + src/spaceandtime/__init__.py | 10 + src/spaceandtime/__main__.py | 23 + src/spaceandtime/apiversions.json | 35 ++ src/spaceandtime/spaceandtime.py | 384 ++++++++++++++ src/spaceandtime/sxtbaseapi.py | 616 ++++++++++++++++++++++ src/spaceandtime/sxtbiscuits.py | 422 +++++++++++++++ src/spaceandtime/sxtenums.py | 76 +++ src/spaceandtime/sxtexceptions.py | 42 ++ src/spaceandtime/sxtkeymanager.py | 323 ++++++++++++ src/spaceandtime/sxtresource.py | 817 ++++++++++++++++++++++++++++++ src/spaceandtime/sxtuser.py | 313 ++++++++++++ validation.py | 54 -- 24 files changed, 3298 insertions(+), 1152 deletions(-) delete mode 100644 .env.sample create mode 100644 MANIFEST.in delete mode 100644 authorize.datalog delete mode 100644 keygen.py delete mode 100644 main.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 setup.py delete mode 100644 spaceandtimesdk.py create mode 100644 src/spaceandtime/.env.sample create mode 100644 src/spaceandtime/__init__.py create mode 100644 src/spaceandtime/__main__.py create mode 100644 src/spaceandtime/apiversions.json create mode 100644 src/spaceandtime/spaceandtime.py create mode 100644 src/spaceandtime/sxtbaseapi.py create mode 100644 src/spaceandtime/sxtbiscuits.py create mode 100644 src/spaceandtime/sxtenums.py create mode 100644 src/spaceandtime/sxtexceptions.py create mode 100644 src/spaceandtime/sxtkeymanager.py create mode 100644 src/spaceandtime/sxtresource.py create mode 100644 src/spaceandtime/sxtuser.py delete mode 100644 validation.py 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://<base_url>/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 <optional .env filepath> + +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..d24f2b5 --- /dev/null +++ b/src/spaceandtime/sxtbiscuits.py @@ -0,0 +1,422 @@ +import logging, json +from pathlib import Path +from datetime import datetime +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']} + __bt: str = '' + __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 = {} + 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): + b = [] + for resource, permissions in self.__cap.items(): + for permission in sorted(permissions): + b.append(f'{self.domain}:capability("{permission}", "{str(resource).lower()}");') + return '\n'.join(b) + + @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 not self.__bt: 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.__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 + + + 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, clear_capabilities() and then add_capability(), 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. + """ + 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) + 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_capability(self, resource:str, *permissions): + """-------------------- + 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 = SXTBiscuitBuilder() + >>> bb.add_capability("Schema.TableA", "SELECT") + 1 + >>> bb.add_capability("Schema.TableA", "INSERT") + True + >>> bb.add_capability("Schema.TableA", "INSERT") + False + """ + 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) + initial_count = len(self.__cap[resource]) + process_count = 0 + final_permissions = [] + for permission in permissions: + process_count += 1 + if type(permission) == list: + if 'ALL' in permission: return self.__isall__(resource) + final_permissions += list(permission) + process_count += len(list(permission))-1 + else: + if permission.name == 'ALL': return self.__isall__(resource) + final_permissions.append(permission) + self.__cap[resource] += [p.value for p in final_permissions] + self.__cap[resource] = list(set(self.__cap[resource])) + self.__bt = '' + added_count = len(self.__cap[resource]) - initial_count + self.logger.info(f'Added {added_count} permissions, from total {process_count} submitted ({process_count - added_count} duplicates)') + return added_count + + def __isall__(self, resource): + total_count = len(self.__cap[resource]) + self.__cap[resource] = ['*'] + self.logger.info(f'Added ALL permissions, replacing a total of {total_count} other permissions.') + return 1 + + + 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. + + """ + 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.__bt = '' + self.logger.debug(f'Successfully translated biscuit_text to biscuit capabilities objects.') + return None + + + def capabilities_from_token(self, biscuit_token:str) -> None: + raise NotImplementedError('Not implemented yet. Please check back later.') + + + def clear_capabilities(self): + """Clears all existing capabilities from the biscuit structure""" + self.__cap = {} + self.__bt = '' + self.logger.debug('Clearing all biscuit capabilities') + 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: + 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.UPDATE) + bis.add_capability('schema.AnotherTable', bis.GRANT.SELECT) + 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_capabilities() + 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_capabilities() + bis.add_capability('schema.NoPermissions') + print( f'-->{bis.biscuit_text}<--' ) + print( f'-->{bis.biscuit_token}<--' ) + + # You can add ALL permissions at once + bis.clear_capabilities() + 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_capabilities() + bis.add_capability('*', bis.GRANT.ALL) + print( bis ) + + # Note, assigning ALL to permissions will remove all other permissions + bis.clear_capabilities() + 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_capabilities() + 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..852505d --- /dev/null +++ b/src/spaceandtime/sxtresource.py @@ -0,0 +1,817 @@ +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 + + 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, 'access_type') + self.__with__= 'WITH "public_key={public_key}, access_type={access_type}"' + + @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.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) - From c178d610279b439409ff0475554ca2e64155caa6 Mon Sep 17 00:00:00 2001 From: Stephen Hilton <Stephen.Hilton@SpaceAndTime.io> Date: Thu, 12 Oct 2023 07:57:49 -0700 Subject: [PATCH 2/3] added immutable flag to SXTTable --- src/spaceandtime/sxtresource.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/spaceandtime/sxtresource.py b/src/spaceandtime/sxtresource.py index 852505d..86a2bdd 100644 --- a/src/spaceandtime/sxtresource.py +++ b/src/spaceandtime/sxtresource.py @@ -565,6 +565,7 @@ def has_with_statement(self, create_ddl:str) ->bool: 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, @@ -573,8 +574,9 @@ def __init__(self, name:str='', from_file:Path=None, default_user:SXTUser = 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}"' + self.__with__= 'WITH "public_key={public_key}, access_type={access_type}, immutable={immutable}"' @property def table_name(self) ->str: @@ -762,6 +764,7 @@ def has_with_statement(self, create_ddl:str) ->bool: , 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 From 83f9c75a9df493a7fd9e698881a53f423c77e590 Mon Sep 17 00:00:00 2001 From: Stephen Hilton <Stephen.Hilton@SpaceAndTime.io> Date: Thu, 12 Oct 2023 14:50:34 -0700 Subject: [PATCH 3/3] Added user and time support for biscuits --- src/spaceandtime/sxtbiscuits.py | 229 +++++++++++++++++++++++++------- 1 file changed, 178 insertions(+), 51 deletions(-) diff --git a/src/spaceandtime/sxtbiscuits.py b/src/spaceandtime/sxtbiscuits.py index d24f2b5..4b7433a 100644 --- a/src/spaceandtime/sxtbiscuits.py +++ b/src/spaceandtime/sxtbiscuits.py @@ -1,6 +1,6 @@ import logging, json from pathlib import Path -from datetime import datetime +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 @@ -18,7 +18,10 @@ class SXTBiscuit(): 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 @@ -36,6 +39,8 @@ def __init__(self, name:str = '', private_key: str = None, new_keypair: bool = F 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: @@ -55,11 +60,14 @@ def __len__(self): @property def biscuit_text(self): - b = [] + 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(permissions): - b.append(f'{self.domain}:capability("{permission}", "{str(resource).lower()}");') - return '\n'.join(b) + 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: @@ -67,7 +75,7 @@ def biscuit_token(self) ->str: if not self.private_key or not self.biscuit_text: self.__bt = '' return '' - if not self.__bt: self.__bt = self.regenerate_biscuit_token() + if self.__btchanged: self.__bt = self.regenerate_biscuit_token() return self.__bt @property @@ -80,7 +88,7 @@ def private_key(self) ->str : @private_key.setter def private_key(self, value): self.key_manager.private_key = value - self.__bt = '' + self.__btchanged = True @property def public_key(self) ->str : @@ -88,6 +96,7 @@ def public_key(self) ->str : @public_key.setter def public_key(self, value): self.key_manager.public_key = value + self.__btchanged = True @property def encoding(self) ->str : @@ -106,9 +115,9 @@ 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, clear_capabilities() and then add_capability(), 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. + 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 @@ -116,6 +125,8 @@ def regenerate_biscuit_token(self) -> dict: 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) @@ -126,6 +137,7 @@ def regenerate_biscuit_token(self) -> dict: 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 @@ -142,9 +154,101 @@ def validate_biscuit(self, biscuit_base64:str, public_key = None) -> str: 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): """-------------------- - Adds a capability to the existing biscuit structure. + Uniquely adds a capability to the existing biscuit structure. Args: resource (str): Resource (Schema.Resource) to which permissions are applied. @@ -154,44 +258,51 @@ def add_capability(self, resource:str, *permissions): int: Number of items added (excluding duplicates) Examples: - >>> bb = SXTBiscuitBuilder() - >>> bb.add_capability("Schema.TableA", "SELECT") + >>> bb = SXTBiscuit() + >>> bb.add_capability("Schema.TableA", bb.GRANT.SELECT) 1 - >>> bb.add_capability("Schema.TableA", "INSERT") - True - >>> bb.add_capability("Schema.TableA", "INSERT") - False + >>> 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) - initial_count = len(self.__cap[resource]) - process_count = 0 + final_permissions = [] - for permission in permissions: - process_count += 1 - if type(permission) == list: - if 'ALL' in permission: return self.__isall__(resource) - final_permissions += list(permission) - process_count += len(list(permission))-1 - else: - if permission.name == 'ALL': return self.__isall__(resource) - final_permissions.append(permission) - self.__cap[resource] += [p.value for p in final_permissions] - self.__cap[resource] = list(set(self.__cap[resource])) - self.__bt = '' - added_count = len(self.__cap[resource]) - initial_count - self.logger.info(f'Added {added_count} permissions, from total {process_count} submitted ({process_count - added_count} duplicates)') + [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 __isall__(self, resource): - total_count = len(self.__cap[resource]) - self.__cap[resource] = ['*'] - self.logger.info(f'Added ALL permissions, replacing a total of {total_count} other permissions.') - return 1 - def capabilities_from_text(self, biscuit_text:str) -> None: """-------------------- @@ -204,6 +315,7 @@ def capabilities_from_text(self, biscuit_text:str) -> None: 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...') @@ -219,20 +331,24 @@ def capabilities_from_text(self, biscuit_text:str) -> None: for r in list(caps.keys()): caps[r] = list(set(caps[r])) self.__cap = caps - self.__bt = '' + self.logger.debug(f'Successfully translated biscuit_text to biscuit capabilities objects.') return None - def capabilities_from_token(self, biscuit_token:str) -> None: + def load_from_token(self, biscuit_token:str) -> None: + self.__btchanged = True raise NotImplementedError('Not implemented yet. Please check back later.') - def clear_capabilities(self): + def clear_biscuit(self): """Clears all existing capabilities from the biscuit structure""" self.__cap = {} - self.__bt = '' - self.logger.debug('Clearing all biscuit capabilities') + self.__usr = [] + self.__time = [] + self.__bt = "" + self.__btchanged = True + self.logger.debug('Clearing all biscuit definitions') return None @@ -328,6 +444,7 @@ def load(self, filepath: Path, resource:str = None, date:str = None, time:str = 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: @@ -350,14 +467,24 @@ def load(self, filepath: Path, resource:str = None, date:str = None, time:str = # BASIC USAGE bis = SXTBiscuit(name='my first biscuit', new_keypair=True) - bis.add_capability('schema.SomeTable', bis.GRANT.SELECT, bis.GRANT.INSERT, bis.GRANT.UPDATE) + 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_capabilities() + 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 @@ -366,23 +493,23 @@ def load(self, filepath: Path, resource:str = None, date:str = None, time:str = print( bis ) # Resources with no permissions never make it into the biscuit - bis.clear_capabilities() + 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_capabilities() + 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_capabilities() + bis.clear_biscuit() bis.add_capability('*', bis.GRANT.ALL) print( bis ) # Note, assigning ALL to permissions will remove all other permissions - bis.clear_capabilities() + bis.clear_biscuit() bis.add_capability('*', bis.GRANT.SELECT, bis.GRANT.INSERT, bis.GRANT.DELETE) bis.add_capability('*', bis.GRANT.ALL) print( bis ) @@ -398,7 +525,7 @@ def load(self, filepath: Path, resource:str = None, date:str = None, time:str = # Save biscuit information to disk, so keys are not lost - bis.clear_capabilities() + 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) )