Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cape.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
name: General
uses: cape-ph/.github/.github/workflows/general_checks.yml@v1
publish:
name: Publsih to PyPI
name: Publish to PyPI
needs: python
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,6 @@ cython_debug/
# sqlite db
*.db
*.sqlite

# Cross-repo implementation plans (not committed)
.design/
1 change: 1 addition & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ _.python.venv = { path = ".venv", create = false }
python = "3.10"
poetry = { version = "2.3", pyproject = "pyproject.toml" }
pre-commit = "3"
opencode = "latest"
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
exclude: ^notes/

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
Expand Down
2 changes: 1 addition & 1 deletion .typos.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# See https://github.com/crate-ci/typos/blob/master/docs/reference.md to configure typos
[files]
extend-exclude = ["roles/*/files"]
extend-exclude = ["roles/*/files", "notes/"]

[default.extend-words]
Furst = "Furst"
71 changes: 71 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Agent Instructions for cape-cod-db

## MANDATORY: Project Context Protocol

**Context is NOT optional. All work on this project MUST include context maintenance.**

### Loading Protocol (REQUIRED)

At the start of EVERY session, you MUST:
1. Read ALL files in `notes/*.md` to load current project context
2. Verify you understand the project structure, architecture, and current state
3. If notes/ doesn't exist or is incomplete, update it before proceeding with work

### Maintenance Protocol (CRITICAL)

When making ANY changes to code:
- Update relevant context notes IN THE SAME SESSION as code changes
- Add new notes files if new subsystems/concepts are introduced
- Keep notes synchronized with code - outdated context is worse than no context

### Completion Checklist

Before marking work as complete, verify:
- [ ] All relevant notes/*.md files have been read
- [ ] Context notes reflect current code state
- [ ] New functionality is documented in appropriate notes
- [ ] Changes to architecture/patterns are reflected in notes
- [ ] Test fixtures (fixtures/test/test_data.sql and cleanup_test_data.sql) are synchronized with schema changes
- [ ] Linting and type checking pass: `poetry run ruff check cape_cod_db/ && poetry run pyright`

**Work is incomplete without synchronized context. No exceptions.**

## Project-Specific Guidelines

### Development Commands

- **Linting**: `poetry run ruff check cape_cod_db/` (check linting) or `poetry run ruff format cape_cod_db/` (auto-format)
- **Type checking**: `poetry run pyright` (configured via pyrightconfig.json)
- **Code formatting**: `poetry run black cape_cod_db/` (Black) or `poetry run isort cape_cod_db/` (import sorting)
- **Testing**: TBD (check if test framework exists before assuming)
- **Database Connection**: Requires `DB_URL` environment variable (set in `.env` file)
- **Test Data**: Load/cleanup test fixtures:
```bash
source .env && psql "$DB_URL" -f fixtures/test/test_data.sql
source .env && psql "$DB_URL" -f fixtures/test/cleanup_test_data.sql
```
- **Migration Commands**:
```bash
# Load DB_URL from .env and run capedb commands
source .env && capedb -c cape_cod_db/alembic.ini -x db_url="$DB_URL" current --verbose
source .env && capedb -c cape_cod_db/alembic.ini -x db_url="$DB_URL" upgrade head
source .env && capedb -c cape_cod_db/alembic.ini -x db_url="$DB_URL" downgrade -1

# Or use alembic directly
alembic -c cape_cod_db/alembic.ini revision --autogenerate -m "description"
```

### Code Conventions

- Python 3.10+ with SQLModel ORM
- Black formatting (line length 80) - migrations excluded
- Use existing patterns from models.py for new models
- All tables inherit from CapeModel base class
- Database migrations via Alembic (never bypass migrations)
- CLI scripts defined in pyproject.toml [tool.poetry.scripts]

### Security

- Never commit database URLs, passwords, or credentials
- Never log PII/PHI (see User model __repr__ TODO)
- Dev-only PostgreSQL setup is documented in README
74 changes: 73 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
# CAPE Environment Database Repo

**_TBD_** blah blah repo for the main cape environment database
Repo for the CAPE environment database. This is the main database used by
various resources in CAPE for tracking users, user attributes, and AWS resource
data.

## Schema Overview

### Authorization Model

The database implements an Attribute-Based Access Control (ABAC) system with the
following components:

- **User**: Core user identity (from Cognito)
- **Tributary**: Logical data compartments with associated AWS resources
- **UserTributary**: Many-to-many junction between users and tributaries
- **Resource**: Platform-agnostic resource definitions (S3 paths, EC2 instances,
etc.) linked to tributaries
- **UserAttribute**: Key-value attributes for users (e.g., `user_status`,
`role`, `department`)

#### Key Relationships

- Users belong to multiple tributaries (via `UserTributary`)
- Each tributary has multiple resources (via `Resource.tributary_id`)
- Resources define access patterns: `s3_read`, `s3_write`, `ec2_connect`, etc.
- User attributes handle non-tributary authorization (quarantine, admin roles,
suspension)

#### Standard Resource Pattern

Each tributary typically has 4 S3 bucket resources:

- `raw_uploads` (write access)
- `clean_uploads` (read access)
- `raw_results` (write access)
- `clean_results` (read access)

## Setup

Expand Down Expand Up @@ -135,6 +169,24 @@ ever.
This script requires the `DB_URL` environment variable be set and does not work
with alembic at all, so the alembic config is not needed.

### Test Data

Test data fixtures are provided in `fixtures/test/`:

- `test_data.sql`: Loads 6 users, 4 tributaries, 12 resources, 9 user attributes
- `cleanup_test_data.sql`: Removes test data in proper dependency order

```bash
# Load test data (use transaction to allow rollback)
source .env && psql "$DB_URL" -f fixtures/test/test_data.sql

# Clean up test data
source .env && psql "$DB_URL" -f fixtures/test/cleanup_test_data.sql
```

**Important**: When modifying schema or test data, keep both SQL files
synchronized with changes.

### Play With The DB

From repo root in the python repl of your fancy (that has all the dependencies
Expand Down Expand Up @@ -179,6 +231,26 @@ with Session(db.engine) as session:

for u in res.all():
print(u)

# query authorization data
with Session(db.engine) as session:
# get user's tributaries
user = session.exec(select(models.User).where(models.User.email == "alice@example.com")).first()
tributaries = session.exec(
select(models.Tributary)
.join(models.UserTributary)
.where(models.UserTributary.user_id == user.id)
).all()

# get resources for a tributary
resources = session.exec(
select(models.Resource).where(models.Resource.tributary_id == tributaries[0].id)
).all()

# get user attributes
attrs = session.exec(
select(models.UserAttribute).where(models.UserAttribute.user_id == user.id)
).all()
```

If you prefer `psql` (**_NOTE:_** this assumes you have all the perms on the
Expand Down
1 change: 0 additions & 1 deletion cape_cod_db/cli/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from cape_cod_db.database import create_tables
from cape_cod_db.models import User

# WARNING: Usage of this script only applies the most recent schema, with 0
# respect to migrations and without including any migration tables or
Expand Down
2 changes: 1 addition & 1 deletion cape_cod_db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# some other source for the DB URL as the config object doesn't exit in
# env.py. alembic doesn't play when we're just doing orm things.
db_url = config.get_main_option("sqlalchemy.url")
except AttributeError as ae:
except AttributeError:
logging.basicConfig()
logger = logging.getLogger(__file__)
logger.warning(
Expand Down
8 changes: 7 additions & 1 deletion cape_cod_db/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
from sqlmodel import SQLModel

# NOTE: as new table models are added, they need to be imported here.
from cape_cod_db.models import User
from cape_cod_db.models import (
Resource,
Tributary,
User,
UserAttribute,
UserTributary,
)

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


import sqlmodel
from alembic import op

# revision identifiers, used by Alembic.
revision: str = '6001985fea71'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""add authorization tables (tributary, user_tributary, resource, user_attribute)

Revision ID: 6919c61ea401
Revises: 6001985fea71
Create Date: 2026-06-25 11:10:27.936819

"""
from typing import Sequence, Union

import sqlalchemy as sa
import sqlmodel
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = '6919c61ea401'
down_revision: Union[str, Sequence[str], None] = '6001985fea71'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tributary',
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_edited', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.Column('attributes', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['tributary.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('tributary', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_tributary_code'), ['code'], unique=True)
batch_op.create_index(batch_op.f('ix_tributary_name'), ['name'], unique=True)

op.create_table('resource',
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_edited', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('resource_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('resource_identifier', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('tributary_id', sa.Integer(), nullable=True),
sa.Column('access_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('attributes', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.ForeignKeyConstraint(['tributary_id'], ['tributary.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('resource', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_resource_resource_identifier'), ['resource_identifier'], unique=True)
batch_op.create_index(batch_op.f('ix_resource_resource_type'), ['resource_type'], unique=False)

# Add GIN index for JSONB attributes column on resource table
op.create_index(
'ix_resource_attributes',
'resource',
['attributes'],
unique=False,
postgresql_using='gin',
postgresql_ops={'attributes': 'jsonb_path_ops'}
)

op.create_table('userattribute',
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_edited', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('attribute_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('attribute_value', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('source', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'attribute_key', name='uq_userattribute_user_id_attribute_key')
)
with op.batch_alter_table('userattribute', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_userattribute_user_id'), ['user_id'], unique=False)

op.create_table('usertributary',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('tributary_id', sa.Integer(), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('granted_at', sa.DateTime(), nullable=False),
sa.Column('granted_by', sa.Integer(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['granted_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['tributary_id'], ['tributary.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'tributary_id')
)
with op.batch_alter_table('usertributary', schema=None) as batch_op:
batch_op.create_index('ix_usertributary_user_id', ['user_id'], unique=False)
batch_op.create_index('ix_usertributary_tributary_id', ['tributary_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('usertributary', schema=None) as batch_op:
batch_op.drop_index('ix_usertributary_tributary_id')
batch_op.drop_index('ix_usertributary_user_id')

op.drop_table('usertributary')
with op.batch_alter_table('userattribute', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_userattribute_user_id'))

op.drop_table('userattribute')

# Drop GIN index on resource attributes
op.drop_index('ix_resource_attributes', table_name='resource')

with op.batch_alter_table('resource', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_resource_resource_type'))
batch_op.drop_index(batch_op.f('ix_resource_resource_identifier'))

op.drop_table('resource')
with op.batch_alter_table('tributary', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_tributary_name'))
batch_op.drop_index(batch_op.f('ix_tributary_code'))

op.drop_table('tributary')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import sqlalchemy as sa
import sqlmodel

from alembic import op

# revision identifiers, used by Alembic.
Expand Down
Loading
Loading