Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authentication in GMT #872

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8de86fd
First version
ArneTR Aug 25, 2024
fe8fed7
Added daily to allowed schedule modes for default user
ArneTR Aug 25, 2024
c3b584e
REmoving user_id from object repr
ArneTR Aug 25, 2024
4f7b2f9
SQL typo
ArneTR Aug 25, 2024
dd13b9c
Fixed timeout tests
ArneTR Aug 25, 2024
7767047
Database is now reloaded from structure file instead of truncate
ArneTR Aug 25, 2024
0b1c4d3
Authenticate now returns user obj instead of just ID
ArneTR Aug 25, 2024
70f0bf5
Updated diff test signature
ArneTR Aug 25, 2024
b9fe53b
Merge branch 'main' into authentication-token
ArneTR Sep 7, 2024
50904a0
Using TRUNCATE CASCADE to clear DB
ArneTR Sep 8, 2024
921943f
Merge branch 'main' into authentication-token
ArneTR Sep 8, 2024
b2e1c99
Merge branch 'main' into authentication-token
ArneTR Sep 8, 2024
deda343
DELETE script for retention expired
ArneTR Sep 8, 2024
f0a7c43
Implemented measurement quota with many tests and refactorings
ArneTR Sep 8, 2024
49ed0c1
Removed noise
ArneTR Sep 8, 2024
8d35b47
Email adding was not possible without user_id
ArneTR Sep 8, 2024
06cfb73
migration needs to be wrapped around [skip ci]
ArneTR Sep 8, 2024
37f3432
user_id added to hog, ci and carbond
ArneTR Sep 8, 2024
83484d6
CarbonDB user_id column [skip ci]
ArneTR Sep 8, 2024
6cb1c5b
Adding user_id to structure
ArneTR Sep 8, 2024
70d1627
Added more JOINs for delete [skip ci]
ArneTR Sep 8, 2024
467a02d
Merge branch 'main' into authentication-token [skip ci]
ArneTR Sep 16, 2024
05077b7
Added machine to error in client.py [skip ci]
ArneTR Sep 16, 2024
35c3ff1
Run-ID link and class name added to errors
ArneTR Sep 18, 2024
df1dee2
Run-ID Link only of not empty [skip ci]
ArneTR Sep 18, 2024
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
6 changes: 3 additions & 3 deletions api/api_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ def get_carbon_intensity(latitude, longitude):

return None

def carbondb_add(client_ip, energydatas):
def carbondb_add(client_ip, energydatas, user_id):

latitude, longitude = get_geo(client_ip)
carbon_intensity = get_carbon_intensity(latitude, longitude)
Expand Down Expand Up @@ -765,12 +765,12 @@ def carbondb_add(client_ip, energydatas):
project_uuid = e['project'] if e['project'] is not None else ''
tags_clean = "{" + ",".join([f'"{tag.strip()}"' for tag in e['tags'].split(',') if e['tags']]) + "}" if e['tags'] is not None else ''

row = f"{e['type']}|{company_uuid}|{e['machine']}|{project_uuid}|{tags_clean}|{int(e['time_stamp'])}|{e['energy_value']}|{co2_value}|{carbon_intensity}|{latitude}|{longitude}|{client_ip}"
row = f"{e['type']}|{company_uuid}|{e['machine']}|{project_uuid}|{tags_clean}|{int(e['time_stamp'])}|{e['energy_value']}|{co2_value}|{carbon_intensity}|{latitude}|{longitude}|{client_ip}|{user_id}"
data_rows.append(row)

data_str = "\n".join(data_rows)
data_file = io.StringIO(data_str)

columns = ['type', 'company', 'machine', 'project', 'tags', 'time_stamp', 'energy_value', 'co2_value', 'carbon_intensity', 'latitude', 'longitude', 'ip_address']
columns = ['type', 'company', 'machine', 'project', 'tags', 'time_stamp', 'energy_value', 'co2_value', 'carbon_intensity', 'latitude', 'longitude', 'ip_address', 'user_id']

DB().copy_from(file=data_file, table='carbondb_energy_data', columns=columns, sep='|')
115 changes: 90 additions & 25 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@
from typing import List
from xml.sax.saxutils import escape as xml_escape
import math
from fastapi import FastAPI, Request, Response
from urllib.parse import urlparse

from fastapi import FastAPI, Request, Response, Depends, HTTPException
from fastapi.responses import ORJSONResponse
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader

from datetime import date

from starlette.responses import RedirectResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.datastructures import Headers as StarletteHeaders

from pydantic import BaseModel, ValidationError, field_validator
from typing import Optional
Expand All @@ -38,6 +43,8 @@
from lib.diff import get_diffable_row, diff_rows
from lib import error_helpers
from lib.job.base import Job
from lib.user import User, UserAuthenticationError
from lib.secure_variable import SecureVariable
from tools.timeline_projects import TimelineProject

from enum import Enum
Expand All @@ -53,7 +60,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
url=request.url,
query_params=request.query_params,
client=request.client,
headers=request.headers,
headers=obfuscate_authentication_token(request.headers),
body=exc.body,
details=exc.errors(),
exception=exc
Expand All @@ -71,7 +78,7 @@ async def http_exception_handler(request, exc):
url=request.url,
query_params=request.query_params,
client=request.client,
headers=request.headers,
headers=obfuscate_authentication_token(request.headers),
body=body,
details=exc.detail,
exception=exc
Expand All @@ -84,6 +91,7 @@ async def http_exception_handler(request, exc):
async def catch_exceptions_middleware(request: Request, call_next):
#pylint: disable=broad-except
body = None

try:
body = await request.body()
return await call_next(request)
Expand All @@ -93,7 +101,7 @@ async def catch_exceptions_middleware(request: Request, call_next):
url=request.url,
query_params=request.query_params,
client=request.client,
headers=request.headers,
headers=obfuscate_authentication_token(request.headers),
body=body,
exception=exc
)
Expand All @@ -117,6 +125,36 @@ async def catch_exceptions_middleware(request: Request, call_next):
allow_headers=['*'],
)

header_scheme = APIKeyHeader(
name='X-Authentication',
scheme_name='Header',
description='Authentication key - See https://docs.green-coding.io/authentication',
auto_error=False
)

def obfuscate_authentication_token(headers: StarletteHeaders):
headers_mut = headers.mutablecopy()
if 'X-Authentication' in headers_mut:
headers_mut['X-Authentication'] = '****OBFUSCATED****'
return headers_mut

def authenticate(authentication_token=Depends(header_scheme), request: Request = None):
parsed_url = urlparse(str(request.url))
try:
user = User.authenticate(SecureVariable(authentication_token)) # Note that if no token is supplied this will authenticate as the DEFAULT user, which in FOSS systems has full capabilities

if not user.can_use_route(parsed_url.path):
raise HTTPException(status_code=401, detail="Route not allowed") from UserAuthenticationError

if not user.has_api_quota(parsed_url.path):
raise HTTPException(status_code=401, detail="Quota exceeded") from UserAuthenticationError

user.deduct_api_quota(parsed_url.path, 1)

except UserAuthenticationError:
raise HTTPException(status_code=401, detail="Invalid token") from UserAuthenticationError
return user


@app.get('/')
async def home():
Expand Down Expand Up @@ -215,6 +253,7 @@ async def get_repositories(uri: str | None = None, branch: str | None = None, ma

return ORJSONResponse({'success': True, 'data': escaped_data})


# A route to return all of the available entries in our catalog.
@app.get('/v1/runs')
async def get_runs(uri: str | None = None, branch: str | None = None, machine_id: int | None = None, machine: str | None = None, filename: str | None = None, limit: int | None = None, uri_mode = 'none'):
Expand Down Expand Up @@ -592,7 +631,6 @@ async def get_jobs(machine_id: int | None = None, state: str | None = None):

return ORJSONResponse({'success': True, 'data': data})

####

class HogMeasurement(BaseModel):
time: int
Expand Down Expand Up @@ -660,7 +698,10 @@ def validate_measurement_data(data):
return True

@app.post('/v1/hog/add')
async def hog_add(measurements: List[HogMeasurement]):
async def hog_add(
measurements: List[HogMeasurement],
user: User = Depends(authenticate), # pylint: disable=unused-argument
):

for measurement in measurements:
decoded_data = base64.b64decode(measurement.data)
Expand Down Expand Up @@ -737,8 +778,9 @@ async def hog_add(measurements: List[HogMeasurement]):
ane_energy,
energy_impact,
thermal_pressure,
settings)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
settings,
user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
params = (
Expand All @@ -752,6 +794,7 @@ async def hog_add(measurements: List[HogMeasurement]):
cpu_energy_data['energy_impact'],
measurement_data['thermal_pressure'],
measurement.settings,
user._id,
)

measurement_db_id = DB().fetch_one(query=query, params=params)[0]
Expand Down Expand Up @@ -1020,9 +1063,6 @@ async def hog_get_task_details(machine_uuid: str, measurements_id_start: int, me
return ORJSONResponse({'success': True, 'tasks_data': tasks_data, 'coalitions_data': coalitions_data})



####

class Software(BaseModel):
name: str
url: str
Expand All @@ -1033,7 +1073,7 @@ class Software(BaseModel):
schedule_mode: str

@app.post('/v1/software/add')
async def software_add(software: Software):
async def software_add(software: Software, user: User = Depends(authenticate)):

software = html_escape_multi(software)

Expand All @@ -1059,22 +1099,26 @@ async def software_add(software: Software):
if not DB().fetch_one('SELECT id FROM machines WHERE id=%s AND available=TRUE', params=(software.machine_id,)):
raise RequestValidationError('Machine does not exist')

if not user.can_use_machine(software.machine_id):
raise RequestValidationError('Your user does not have the permissions to use that machine.')

if software.schedule_mode not in ['one-off', 'daily', 'weekly', 'commit', 'variance']:
raise RequestValidationError(f"Please select a valid measurement interval. ({software.schedule_mode}) is unknown.")

# notify admin of new add
if notification_email := GlobalConfig().config['admin']['notification_email']:
Job.insert('email', name='New run added from Web Interface', message=str(software), email=notification_email)

if not user.can_schedule_job(software.schedule_mode):
raise RequestValidationError('Your user does not have the permissions to use that schedule mode.')

if software.schedule_mode in ['daily', 'weekly', 'commit']:
TimelineProject.insert(software.name, software.url, software.branch, software.filename, software.machine_id, software.schedule_mode)
TimelineProject.insert(name=software.name, url=software.url, branch=software.branch, filename=software.filename, machine_id=software.machine_id, user_id=user._id, schedule_mode=software.schedule_mode)

# even for timeline projects we do at least one run
amount = 10 if software.schedule_mode == 'variance' else 1
for _ in range(0,amount):
Job.insert('run', name=software.name, url=software.url, email=software.email, branch=software.branch, filename=software.filename, machine_id=software.machine_id)
Job.insert('run', user_id=user._id, name=software.name, url=software.url, email=software.email, branch=software.branch, filename=software.filename, machine_id=software.machine_id)

# notify admin of new add
if notification_email := GlobalConfig().config['admin']['notification_email']:
Job.insert('email', user_id=user._id, name='New run added from Web Interface', message=str(software), email=notification_email)

return ORJSONResponse({'success': True}, status_code=202)

Expand Down Expand Up @@ -1167,7 +1211,11 @@ class CI_Measurement(BaseModel):
co2eq: Optional[str] = ''

@app.post('/v1/ci/measurement/add')
async def post_ci_measurement_add(request: Request, measurement: CI_Measurement):
async def post_ci_measurement_add(
request: Request,
measurement: CI_Measurement,
user: User = Depends(authenticate) # pylint: disable=unused-argument
):
for key, value in measurement.model_dump().items():
match key:
case 'unit':
Expand Down Expand Up @@ -1208,14 +1256,15 @@ async def post_ci_measurement_add(request: Request, measurement: CI_Measurement)
lon,
city,
co2i,
co2eq
co2eq,
user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
params = (measurement.energy_value, measurement.energy_unit, measurement.repo, measurement.branch,
measurement.workflow, measurement.run_id, measurement.label, measurement.source, measurement.cpu,
measurement.commit_hash, measurement.duration, measurement.cpu_util_avg, measurement.workflow_name,
measurement.lat, measurement.lon, measurement.city, measurement.co2i, measurement.co2eq)
measurement.lat, measurement.lon, measurement.city, measurement.co2i, measurement.co2eq, user._id)

DB().query(query=query, params=params)

Expand All @@ -1242,7 +1291,7 @@ async def post_ci_measurement_add(request: Request, measurement: CI_Measurement)
}

# If there is an error the function will raise an Error
carbondb_add(client_ip, [energydata])
carbondb_add(client_ip, [energydata], user._id)

return ORJSONResponse({'success': True}, status_code=201)

Expand Down Expand Up @@ -1379,15 +1428,19 @@ def empty_str_to_none(cls, values, _):
return values

@app.post('/v1/carbondb/add')
async def add_carbondb(request: Request, energydatas: List[EnergyData]):
async def add_carbondb(
request: Request,
energydatas: List[EnergyData],
user: User = Depends(authenticate) # pylint: disable=unused-argument
):

client_ip = request.headers.get("x-forwarded-for")
if client_ip:
client_ip = client_ip.split(",")[0]
else:
client_ip = request.client.host

carbondb_add(client_ip, energydatas)
carbondb_add(client_ip, energydatas, user._id)

return Response(status_code=204)

Expand Down Expand Up @@ -1443,5 +1496,17 @@ async def carbondb_get_company_project_details(cptype: str, uuid: str):

return ORJSONResponse({'success': True, 'data': data})

# @app.get('/v1/authentication/new')
# async def get_authentication_token(name: str = None):
# if name is not None and name.strip() == '':
# name = None
# return ORJSONResponse({'success': True, 'data': User.get_new(name)})

@app.get('/v1/authentication/data')
async def read_authentication_token(user: User = Depends(authenticate)):
return ORJSONResponse({'success': True, 'data': user.__dict__})



if __name__ == '__main__':
app.run() # pylint: disable=no-member
3 changes: 0 additions & 3 deletions config.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ admin:
email_bcc: False



cluster:
api_url: __API_URL__
metrics_url: __METRICS_URL__
Expand Down Expand Up @@ -65,8 +64,6 @@ measurement:
pre-test-sleep: 5
idle-duration: 5
baseline-duration: 5
flow-process-duration: 1800 # half hour
total-duration: 3600 # one hour
post-test-sleep: 5
phase-transition-time: 1
boot:
Expand Down
Loading